From 430043b5ac0cc04170447252338af06cb0ab0097 Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Thu, 2 Jul 2026 14:46:16 +0200 Subject: [PATCH 01/23] =?UTF-8?q?Vorbereitung=20der=20API=20Struktur=20+?= =?UTF-8?q?=20Bereitstellung=20des=20Ger=C3=BCsts=20f=C3=BCr=20Weights=20u?= =?UTF-8?q?nd=20Availabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scheduling/api/app.py | 7 +- src/scheduling/api/timeoffice/router.py | 0 src/scheduling/api/timeoffice/schemas.py | 16 ++++ src/scheduling/api/web/availability_router.py | 88 +++++++++++++++++++ .../api/web/{router.py => employee_router.py} | 4 +- .../api/web/minimal_staff_router.py | 0 src/scheduling/api/web/schemas.py | 19 ++++ src/scheduling/api/web/weights_router.py | 69 +++++++++++++++ src/scheduling/api/web/wishes_router.py | 0 9 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 src/scheduling/api/timeoffice/router.py create mode 100644 src/scheduling/api/timeoffice/schemas.py create mode 100644 src/scheduling/api/web/availability_router.py rename src/scheduling/api/web/{router.py => employee_router.py} (94%) create mode 100644 src/scheduling/api/web/minimal_staff_router.py create mode 100644 src/scheduling/api/web/schemas.py create mode 100644 src/scheduling/api/web/weights_router.py create mode 100644 src/scheduling/api/web/wishes_router.py diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index 12d1a836..e171c487 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -8,7 +8,8 @@ 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.availability_router import availability_router +from scheduling.api.web.employee_router import employee_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 @@ -55,7 +56,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app = FastAPI(title="Staff Scheduling API", lifespan=lifespan) app.include_router(solve_router) -app.include_router(web_router) +app.include_router(employee_router) +# app.include_router(weights_router) +app.include_router(availability_router) @app.get("/status") diff --git a/src/scheduling/api/timeoffice/router.py b/src/scheduling/api/timeoffice/router.py new file mode 100644 index 00000000..e69de29b diff --git a/src/scheduling/api/timeoffice/schemas.py b/src/scheduling/api/timeoffice/schemas.py new file mode 100644 index 00000000..abebc972 --- /dev/null +++ b/src/scheduling/api/timeoffice/schemas.py @@ -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 diff --git a/src/scheduling/api/web/availability_router.py b/src/scheduling/api/web/availability_router.py new file mode 100644 index 00000000..44dd63d9 --- /dev/null +++ b/src/scheduling/api/web/availability_router.py @@ -0,0 +1,88 @@ +import logging +from datetime import date +from typing import Annotated, Any + +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.domain import Availability, AvailabilityType, Employee, PlanningMonth +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + +availability_router = APIRouter() + + +@availability_router.get("/availability") +async def get_availability( + planning_unit: int, + year: int, + month: int, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> dict[str, list[dict[str, Any]]]: + month = PlanningMonth(year=year, month=month) + + dataset = timeoffice.fetch_dataset( + planning_unit_ids=(planning_unit,), + planning_month=month, + ) + + return {"employees": [_availability_to_frontend(employee, dataset.availability) for employee in dataset.employees]} + + +def _availability_to_frontend( + employee: Employee, + availabilities: tuple[Availability, ...], +) -> dict[str, Any]: + name, firstname = _split_display_name(employee.display_name) + + employee_availabilities = [ + availability for availability in availabilities if availability.employee_id == employee.employee_id + ] + + return { + "key": employee.employee_id, + "firstname": firstname, + "name": name, + "availability_days": [ + availability.date.day + for availability in employee_availabilities + if availability.availability_type == AvailabilityType.AVAILABLE_ONLY + ], + "unavailability_days": [ + availability.date.day + for availability in employee_availabilities + if availability.availability_type != AvailabilityType.AVAILABLE_ONLY + ], + } + + +def _split_display_name(display_name: str) -> tuple[str, str]: + name, _separator, firstname = display_name.partition(" ") + return name, firstname + + +@availability_router.put("/availability") +async def put_availability( + planning_unit: int, + from_date: date, + request: dict[str, Any], # Hier gerne wieder ein Request in Schema? +) -> dict[str, bool]: + month = PlanningMonth(year=from_date.year, month=from_date.month) + availability_json = request.get("data", {}) + + logger.info( + "Received availability update: planning_unit=%s planning_month=%s availability=%s", + planning_unit, + month.label, + availability_json, + ) + # TODO: Mappen der JSON auf das Domain + # TODO: Aufruf der Datenbank + + logger.info("Update availability in database") + + return {"success": True} + + +# TODO: Global Availabilities fehlen und werden zur Zeit nicht mit modelliert diff --git a/src/scheduling/api/web/router.py b/src/scheduling/api/web/employee_router.py similarity index 94% rename from src/scheduling/api/web/router.py rename to src/scheduling/api/web/employee_router.py index 80d8e90a..ed57cfbb 100644 --- a/src/scheduling/api/web/router.py +++ b/src/scheduling/api/web/employee_router.py @@ -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, diff --git a/src/scheduling/api/web/minimal_staff_router.py b/src/scheduling/api/web/minimal_staff_router.py new file mode 100644 index 00000000..e69de29b diff --git a/src/scheduling/api/web/schemas.py b/src/scheduling/api/web/schemas.py new file mode 100644 index 00000000..e9f407d5 --- /dev/null +++ b/src/scheduling/api/web/schemas.py @@ -0,0 +1,19 @@ +from pydantic import Field + +from scheduling.domain.core import SchedulingBaseModel + + +class AvailabilityEmployeeRequest(SchedulingBaseModel): + key: int + firstname: str | None = None + name: str | None = None + availability_days: tuple[int, ...] = Field(default_factory=tuple) + unavailability_days: tuple[int, ...] = Field(default_factory=tuple) + + +class AvailabilityDatabaseRequest(SchedulingBaseModel): + employees: tuple[AvailabilityEmployeeRequest, ...] + + +class UpdateAvailabilityRequest(SchedulingBaseModel): + data: AvailabilityDatabaseRequest diff --git a/src/scheduling/api/web/weights_router.py b/src/scheduling/api/web/weights_router.py new file mode 100644 index 00000000..1d4e7766 --- /dev/null +++ b/src/scheduling/api/web/weights_router.py @@ -0,0 +1,69 @@ +import logging +from datetime import date +from typing import Annotated, Any + +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.domain import PlanningMonth # Hier muss später noch Wish stehen +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + +weights_router = APIRouter() + + +DEFAULT_WEIGHTS: dict[ + str, Any +] = {} # TODO: Default weights sollten in der Datenbank stehen und später ausgelesen werden + + +@weights_router.get("/weights") +async def get_weights( + planning_unit: int, + from_date: date, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> dict[str, Any]: + """Return weights for a planning unit and month. + + TODO: Ersetzen des Kommentars fürs fetchen mit der richtigen Funktion + TODO: Default weights in die Datenbank schreiben und die dann fetchen + """ + # Wäre schöner, wenn Monat und Jahr im frontend übergeben werden -> ggf. noch ändern + month = PlanningMonth(year=from_date.year, month=from_date.month) + # weights = timeoffice.fetch_dataset(planning_unit_ids=(planning_unit,), planning_month=month).weights + logger.info( + "Fetching weights: planning_unit=%s planning_month=%s", + planning_unit, + month.label, + ) + # if weights is None: + # TODO: weights = Methode fetch default weights oder so + # TODO: Umwandeln der weights in die entsprechende json + # return weights + + return DEFAULT_WEIGHTS # Muss durch weights ersetzt werden und dann können Default weights gelöscht werden + + +@weights_router.put("/weights") +async def put_weights( + planning_unit: int, + from_date: date, + request: dict[str, Any], # Vielleicht schöner dem Request ein Schema zu geben +) -> dict[str, bool]: + """Update weights for a planning unit and month. + + TODO: Überführen der Gewichte ins Domain + Schreiben der Gewichte in die Datenbank + """ + month = PlanningMonth(year=from_date.year, month=from_date.month) + weights_json = request.get("data", {}) + + logger.info( + "Received weights update: planning_unit=%s planning_month=%s weights=%s", + planning_unit, + month.label, + weights_json, + ) + # TODO: Überführen der weights_json in das Domain + logger.info("Update Weights in Database") + return {"success": True} diff --git a/src/scheduling/api/web/wishes_router.py b/src/scheduling/api/web/wishes_router.py new file mode 100644 index 00000000..e69de29b From 26ed79482c32161ac68da50321168802bc27594d Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Sun, 5 Jul 2026 11:45:03 +0200 Subject: [PATCH 02/23] Global Wishes zu Weekly Wishes gemacht und die API entsprechend angepasst --- src/scheduling/api/app.py | 5 +- src/scheduling/api/web/schemas.py | 22 ++ src/scheduling/api/web/wishes_router.py | 331 +++++++++++++++++++ src/scheduling/domain/__init__.py | 6 +- src/scheduling/domain/dataset.py | 32 +- src/scheduling/domain/planning_month.py | 29 ++ src/scheduling/domain/wish.py | 13 + src/scheduling/timeoffice/facts.py | 8 +- src/scheduling/timeoffice/mapping/dataset.py | 8 +- src/scheduling/timeoffice/mapping/wishes.py | 121 ++++++- 10 files changed, 530 insertions(+), 45 deletions(-) create mode 100644 src/scheduling/domain/planning_month.py diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index e171c487..3eac03f5 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -8,8 +8,8 @@ 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.availability_router import availability_router from scheduling.api.web.employee_router import employee_router +from scheduling.api.web.wishes_router import wishes_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 @@ -58,7 +58,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(solve_router) app.include_router(employee_router) # app.include_router(weights_router) -app.include_router(availability_router) +# app.include_router(availability_router) +app.include_router(wishes_router) @app.get("/status") diff --git a/src/scheduling/api/web/schemas.py b/src/scheduling/api/web/schemas.py index e9f407d5..160eb5d6 100644 --- a/src/scheduling/api/web/schemas.py +++ b/src/scheduling/api/web/schemas.py @@ -17,3 +17,25 @@ class AvailabilityDatabaseRequest(SchedulingBaseModel): class UpdateAvailabilityRequest(SchedulingBaseModel): data: AvailabilityDatabaseRequest + + +class WishesAndBlockedEmployeeRequest(SchedulingBaseModel): + key: int + firstname: str | None = None + name: str | None = None + wish_days: tuple[int, ...] = Field(default_factory=tuple) + wish_shifts: tuple[tuple[int, str], ...] = Field(default_factory=tuple) + blocked_days: tuple[int, ...] = Field(default_factory=tuple) + blocked_shifts: tuple[tuple[int, str], ...] = Field(default_factory=tuple) + + +class WishesAndBlockedDatabaseRequest(SchedulingBaseModel): + employees: tuple[WishesAndBlockedEmployeeRequest, ...] + + +class UpdateWishesAndBlockedRequest(SchedulingBaseModel): + data: WishesAndBlockedDatabaseRequest + + +class SuccessResponse(SchedulingBaseModel): + success: bool = True diff --git a/src/scheduling/api/web/wishes_router.py b/src/scheduling/api/web/wishes_router.py index e69de29b..a3ba2767 100644 --- a/src/scheduling/api/web/wishes_router.py +++ b/src/scheduling/api/web/wishes_router.py @@ -0,0 +1,331 @@ +import logging +from datetime import date +from typing import Annotated, Any + +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.api.web.schemas import SuccessResponse, UpdateWishesAndBlockedRequest, WishesAndBlockedDatabaseRequest +from scheduling.domain import Employee, PlanningMonth, WeeklyWish, Wish, WishType +from scheduling.timeoffice.facts import TIMEOFFICE_FACTS +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + +wishes_router = APIRouter() + + +@wishes_router.get("/wishes-and-blocked") +async def get_wishes_and_blocked( + planning_unit: int, + month: int, + year: int, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> dict[str, list[dict[str, Any]]]: + month = PlanningMonth(year=year, month=month) + + dataset = timeoffice.fetch_dataset( + planning_unit_ids=(planning_unit,), + planning_month=month, + ) + + return { + "employees": [ + _wishes_to_frontend( + employee=employee, + wishes=dataset.wishes, + ) + for employee in dataset.employees + ] + } + + +def _wishes_to_frontend( + *, + employee: Employee, + wishes: tuple[Wish, ...], +) -> dict[str, Any]: + name, firstname = _split_display_name(employee.display_name) + + employee_wishes = [wish for wish in wishes if wish.employee_id == employee.employee_id] + + return { + "key": employee.employee_id, + "firstname": firstname, + "name": name, + "wish_days": [wish.date.day for wish in employee_wishes if wish.type == WishType.PREFERRED_DAY], + "wish_shifts": [ + [wish.date.day, _wish_shift_to_frontend(wish)] + for wish in employee_wishes + if wish.type == WishType.PREFERRED_SHIFT + ], + "blocked_days": [wish.date.day for wish in employee_wishes if wish.type == WishType.FREE_DAY], + "blocked_shifts": [ + [wish.date.day, _wish_shift_to_frontend(wish)] + for wish in employee_wishes + if wish.type == WishType.FREE_SHIFT + ], + } + + +def _wish_shift_to_frontend(wish: Wish) -> str: + if wish.shift_id is None: + raise ValueError(f"{wish.type} wish requires shift_id.") + + shift_fact = TIMEOFFICE_FACTS.reference_shift_facts_by_id.get(wish.shift_id) + + if shift_fact is None: + raise ValueError(f"Unknown reference shift for wish: employee_id={wish.employee_id} ") + + return shift_fact.expected_code + + +def _split_display_name(display_name: str) -> tuple[str, str]: + name, _separator, firstname = display_name.partition(" ") + return name, firstname + + +@wishes_router.put("/wishes-and-blocked") +async def put_wishes_and_blocked( + planning_unit: int, + month: int, + year: int, + request: UpdateWishesAndBlockedRequest, +) -> SuccessResponse: + month = PlanningMonth(year=year, month=month) + + wishes = _wishes_request_to_domain( + request=request.data, + planning_unit=planning_unit, + planning_month=month, + ) + + logger.info( + "Received global wishes update: planning_unit=%s planning_month=%s weekly_wishes=%s", + planning_unit, + month, + len(wishes), + ) + + # TODO: In Datenbank schreiben + # timeoffice.update_wishes_and_blocked( + # planning_unit_id=planning_unit, + # planning_month=month, + # wishes=wishes, + # ) + + logger.info("Update wishes and blocked in database") + + return SuccessResponse() + + +def _wishes_request_to_domain( + *, + request: WishesAndBlockedDatabaseRequest, + planning_unit: int, + planning_month: PlanningMonth, +) -> tuple[Wish, ...]: + wishes: list[Wish] = [] + + for employee in request.employees: + for day in employee.wish_days: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.PREFERRED_DAY, + ) + ) + + for day, shift_code in employee.wish_shifts: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.PREFERRED_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + for day in employee.blocked_days: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.FREE_DAY, + ) + ) + + for day, shift_code in employee.blocked_shifts: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.FREE_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + return tuple(wishes) + + +def _shift_id_from_frontend(shift_code: str) -> int: + 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 from wishes frontend: {shift_code!r}.") + + +@wishes_router.get("/global-wishes-and-blocked") +async def get_global_wishes_and_blocked( + planning_unit: int, + month: int, + year: int, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> dict[str, list[dict[str, Any]]]: + planning_month = PlanningMonth(year=year, month=month) + + dataset = timeoffice.fetch_dataset( + planning_unit_ids=(planning_unit,), + planning_month=planning_month, + ) + + return { + "employees": [ + _weekly_wishes_to_frontend( + employee=employee, + weekly_wishes=dataset.weekly_wishes, + ) + for employee in dataset.employees + ] + } + + +def _weekly_wishes_to_frontend( + *, + employee: Employee, + weekly_wishes: tuple[WeeklyWish, ...], +) -> dict[str, Any]: + name, firstname = _split_display_name(employee.display_name) + + employee_wishes = [wish for wish in weekly_wishes if wish.employee_id == employee.employee_id] + + return { + "key": employee.employee_id, + "firstname": firstname, + "name": name, + "wish_days": [wish.weekday for wish in employee_wishes if wish.type == WishType.PREFERRED_DAY], + "wish_shifts": [ + [wish.weekday, _weekly_wish_shift_to_frontend(wish)] + for wish in employee_wishes + if wish.type == WishType.PREFERRED_SHIFT + ], + "blocked_days": [wish.weekday for wish in employee_wishes if wish.type == WishType.FREE_DAY], + "blocked_shifts": [ + [wish.weekday, _weekly_wish_shift_to_frontend(wish)] + for wish in employee_wishes + if wish.type == WishType.FREE_SHIFT + ], + } + + +def _weekly_wish_shift_to_frontend(wish: WeeklyWish) -> str: + if wish.shift_id is None: + raise ValueError(f"{wish.type} weekly wish requires shift_id.") + + shift_fact = TIMEOFFICE_FACTS.reference_shift_facts_by_id.get(wish.shift_id) + + if shift_fact is None: + raise ValueError(f"Unknown reference shift for weekly wish: employee_id={wish.employee_id}") + + return shift_fact.expected_code + + +@wishes_router.put("/global-wishes-and-blocked") +async def put_global_wishes_and_blocked( + planning_unit: int, + month: int, + year: int, + request: UpdateWishesAndBlockedRequest, +) -> SuccessResponse: + planning_month = PlanningMonth(year=year, month=month) + + weekly_wishes = _weekly_wishes_request_to_domain( + request=request.data, + planning_unit=planning_unit, + planning_month=planning_month, + ) + + logger.info( + "Received global wishes update: planning_unit=%s planning_month=%s weekly_wishes=%s", + planning_unit, + planning_month.label, + len(weekly_wishes), + ) + + # TODO: In Datenbank schreiben + + return SuccessResponse() + + +def _weekly_wishes_request_to_domain( + *, + request: WishesAndBlockedDatabaseRequest, + planning_unit: int, + planning_month: PlanningMonth, +) -> tuple[WeeklyWish, ...]: + weekly_wishes: list[WeeklyWish] = [] + + for employee in request.employees: + for weekday in employee.wish_days: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.PREFERRED_DAY, + ) + ) + + for weekday, shift_code in employee.wish_shifts: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.PREFERRED_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + for weekday in employee.blocked_days: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.FREE_DAY, + ) + ) + + for weekday, shift_code in employee.blocked_shifts: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.FREE_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + return tuple(weekly_wishes) diff --git a/src/scheduling/domain/__init__.py b/src/scheduling/domain/__init__.py index a22e1b50..5d35d4a4 100644 --- a/src/scheduling/domain/__init__.py +++ b/src/scheduling/domain/__init__.py @@ -1,15 +1,16 @@ from scheduling.domain.assignment import Assignment, AssignmentType from scheduling.domain.availability import Availability, AvailabilityType from scheduling.domain.core import MinuteOfDay, NonEmptyStr, NonNegativeInt, PositiveId, SchedulingBaseModel -from scheduling.domain.dataset import PlanningMonth, SchedulingDataset +from scheduling.domain.dataset import SchedulingDataset from scheduling.domain.demand import DemandRequirement from scheduling.domain.employee import Capability, Employee, EmployeeId, StaffLevel from scheduling.domain.monthly_work_account import MonthlyWorkAccount from scheduling.domain.plan import Plan, PlanId +from scheduling.domain.planning_month import PlanningMonth from scheduling.domain.planning_unit import PlanningUnit, PlanningUnitId, PlanningUnitMembership, PlanningUnitType from scheduling.domain.shift import Shift, ShiftId, ShiftType, StaffingDemandRole from scheduling.domain.sunday_work_history import EmployeeSundayWorkHistory -from scheduling.domain.wish import Wish, WishType +from scheduling.domain.wish import WeeklyWish, Wish, WishType __all__ = [ "PositiveId", @@ -41,5 +42,6 @@ "EmployeeSundayWorkHistory", "Wish", "WishType", + "WeeklyWish", "MonthlyWorkAccount", ] diff --git a/src/scheduling/domain/dataset.py b/src/scheduling/domain/dataset.py index a32d8a32..8279dbfc 100644 --- a/src/scheduling/domain/dataset.py +++ b/src/scheduling/domain/dataset.py @@ -1,8 +1,3 @@ -from calendar import monthrange -from datetime import date - -from pydantic import Field, computed_field - from scheduling.domain import SchedulingBaseModel from scheduling.domain.assignment import Assignment from scheduling.domain.availability import Availability @@ -10,33 +5,11 @@ from scheduling.domain.employee import Employee from scheduling.domain.monthly_work_account import MonthlyWorkAccount from scheduling.domain.plan import Plan +from scheduling.domain.planning_month import PlanningMonth from scheduling.domain.planning_unit import PlanningUnit, PlanningUnitMembership from scheduling.domain.shift import Shift from scheduling.domain.sunday_work_history import EmployeeSundayWorkHistory -from scheduling.domain.wish import Wish - - -class PlanningMonth(SchedulingBaseModel): - year: int = Field(ge=2000, le=2200) - month: int = Field(ge=1, le=12) - - @computed_field - @property - def start(self) -> date: - return date(self.year, self.month, 1) - - @computed_field - @property - def end(self) -> date: - return date( - self.year, - self.month, - monthrange(self.year, self.month)[1], - ) - - @property - def label(self) -> str: - return f"{self.year:04d}-{self.month:02d}" +from scheduling.domain.wish import WeeklyWish, Wish class SchedulingDataset(SchedulingBaseModel): @@ -58,6 +31,7 @@ class SchedulingDataset(SchedulingBaseModel): planning_unit_memberships: tuple[PlanningUnitMembership, ...] = () sunday_work_history: tuple[EmployeeSundayWorkHistory, ...] = () wishes: tuple[Wish, ...] = () + weekly_wishes: tuple[WeeklyWish, ...] assignments: tuple[Assignment, ...] = () availability: tuple[Availability, ...] = () diff --git a/src/scheduling/domain/planning_month.py b/src/scheduling/domain/planning_month.py new file mode 100644 index 00000000..871ebabf --- /dev/null +++ b/src/scheduling/domain/planning_month.py @@ -0,0 +1,29 @@ +from calendar import monthrange +from datetime import date + +from pydantic import Field, computed_field + +from scheduling.domain import SchedulingBaseModel + + +class PlanningMonth(SchedulingBaseModel): + year: int = Field(ge=2000, le=2200) + month: int = Field(ge=1, le=12) + + @computed_field + @property + def start(self) -> date: + return date(self.year, self.month, 1) + + @computed_field + @property + def end(self) -> date: + return date( + self.year, + self.month, + monthrange(self.year, self.month)[1], + ) + + @property + def label(self) -> str: + return f"{self.year:04d}-{self.month:02d}" diff --git a/src/scheduling/domain/wish.py b/src/scheduling/domain/wish.py index dfde8d07..cca29436 100644 --- a/src/scheduling/domain/wish.py +++ b/src/scheduling/domain/wish.py @@ -6,6 +6,7 @@ from scheduling.domain.core import SchedulingBaseModel from scheduling.domain.employee import EmployeeId +from scheduling.domain.planning_month import PlanningMonth from scheduling.domain.planning_unit import PlanningUnitId from scheduling.domain.shift import ShiftId @@ -34,3 +35,15 @@ def validate_wish(self) -> Self: raise ValueError(f"{self.type} wish must not define shift_id.") return self + + +class WeeklyWish(SchedulingBaseModel): + employee_id: EmployeeId + planning_unit_id: PlanningUnitId + planning_month: PlanningMonth + + weekday: int # weekday: 1=Monday, 7=Sunday + type: WishType + shift_id: ShiftId | None = None + + # TODO: Validator fehlt noch diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index c315ba0c..12e0e7f5 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -105,22 +105,22 @@ class TimeOfficeFacts: REFERENCE_SHIFT_FACTS_BY_ID: Mapping[ShiftId, TimeOfficeReferenceShiftFact] = MappingProxyType( { EARLY_F2_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="F2_", + expected_code="F", type=ShiftType.EARLY, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, ), LATE_S2_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="S2_", + expected_code="S", type=ShiftType.LATE, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, ), NIGHT_N2_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="N2_", + expected_code="N", type=ShiftType.NIGHT, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, ), INTERMEDIATE_T75_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="T75_", + expected_code="T", type=ShiftType.INTERMEDIATE, staffing_role=StaffingDemandRole.OPTIONAL_COVERAGE, ), diff --git a/src/scheduling/timeoffice/mapping/dataset.py b/src/scheduling/timeoffice/mapping/dataset.py index 1a3a0b9c..bf22473a 100644 --- a/src/scheduling/timeoffice/mapping/dataset.py +++ b/src/scheduling/timeoffice/mapping/dataset.py @@ -20,6 +20,7 @@ def map_scheduling_dataset( planning_units = map_planning_units(sources.planning_unit_rows, facts=facts) plans = map_plans(sources.planning_unit_rows) shifts = map_shifts(sources.shift_rows, facts=facts) + mapped_wishes = map_wishes(rows=sources.wish_rows, shifts=shifts, facts=facts) return SchedulingDataset( planning_month=planning_month, @@ -47,10 +48,7 @@ def map_scheduling_dataset( facts=facts, ), sunday_work_history=map_sunday_work_history(sources.sunday_history_rows), - wishes=map_wishes( - rows=sources.wish_rows, - shifts=shifts, - facts=facts, - ), + wishes=mapped_wishes.wishes, + weekly_wishes=mapped_wishes.weekly_wishes, monthly_work_accounts=map_monthly_work_accounts(sources.monthly_work_account_rows), ) diff --git a/src/scheduling/timeoffice/mapping/wishes.py b/src/scheduling/timeoffice/mapping/wishes.py index 2649a907..53b3672d 100644 --- a/src/scheduling/timeoffice/mapping/wishes.py +++ b/src/scheduling/timeoffice/mapping/wishes.py @@ -1,17 +1,27 @@ +from calendar import monthrange +from collections import defaultdict +from dataclasses import dataclass from datetime import date as Date +from datetime import timedelta -from scheduling.domain import Shift, Wish, WishType +from scheduling.domain import PlanningMonth, Shift, WeeklyWish, Wish, WishType from scheduling.timeoffice.facts import TimeOfficeFacts from scheduling.timeoffice.mapping.shifts import reference_shift_id_for_source_shift from scheduling.timeoffice.reading.wishes import TimeOfficeWishRow +@dataclass(frozen=True, slots=True) +class MappedWishes: + wishes: tuple[Wish, ...] + weekly_wishes: tuple[WeeklyWish, ...] + + def map_wishes( rows: tuple[TimeOfficeWishRow, ...], *, shifts: tuple[Shift, ...], facts: TimeOfficeFacts, -) -> tuple[Wish, ...]: +) -> MappedWishes: known_shift_ids = {shift.shift_id for shift in shifts} wishes = tuple( @@ -23,7 +33,9 @@ def map_wishes( for row in rows ) - return _deduplicate_wishes(wishes) + wishes = _deduplicate_wishes(wishes) + + return _split_weekly_repeated_wishes(wishes) def _map_wish( @@ -156,3 +168,106 @@ def _deduplicate_wishes(wishes: tuple[Wish, ...]) -> tuple[Wish, ...]: ), ) ) + + +def _split_weekly_repeated_wishes(wishes: tuple[Wish, ...]) -> MappedWishes: + wishes_by_key: dict[tuple[int, int, WishType, int | None, int], list[Wish]] = defaultdict(list) + + for wish in wishes: + key = ( + wish.employee_id, + wish.planning_unit_id, + wish.type, + wish.shift_id, + wish.date.isoweekday(), # 1=Mo, 7=So + ) + wishes_by_key[key].append(wish) + + normal_wishes: list[Wish] = [] + weekly_wishes: list[WeeklyWish] = [] + + for grouped_wishes in wishes_by_key.values(): + sorted_wishes = sorted(grouped_wishes, key=lambda wish: wish.date) + first_wish = sorted_wishes[0] + + if _is_weekly_repeated_in_month(sorted_wishes): + weekly_wishes.append( + WeeklyWish( + employee_id=first_wish.employee_id, + planning_unit_id=first_wish.planning_unit_id, + planning_month=PlanningMonth( + year=first_wish.date.year, + month=first_wish.date.month, + ), + weekday=first_wish.date.isoweekday(), + type=first_wish.type, + shift_id=first_wish.shift_id, + ) + ) + else: + normal_wishes.extend(sorted_wishes) + + return MappedWishes( + wishes=_sort_wishes(tuple(normal_wishes)), + weekly_wishes=_sort_weekly_wishes(tuple(weekly_wishes)), + ) + + +def _sort_wishes(wishes: tuple[Wish, ...]) -> tuple[Wish, ...]: + return tuple( + sorted( + wishes, + key=lambda wish: ( + wish.employee_id, + wish.planning_unit_id, + wish.date, + wish.type.value, + wish.shift_id or -1, + ), + ) + ) + + +def _sort_weekly_wishes(weekly_wishes: tuple[WeeklyWish, ...]) -> tuple[WeeklyWish, ...]: + return tuple( + sorted( + weekly_wishes, + key=lambda wish: ( + wish.employee_id, + wish.planning_unit_id, + wish.planning_month.year, + wish.planning_month.month, + wish.weekday, + wish.type.value, + wish.shift_id or -1, + ), + ) + ) + + +def _is_weekly_repeated_in_month(wishes: list[Wish]) -> bool: + if len(wishes) < 2: + return False + + dates = {wish.date for wish in wishes} + first_date = min(dates) + + expected_dates = set(_same_weekday_dates_in_month(first_date)) + + return dates == expected_dates + + +def _same_weekday_dates_in_month(first_date: Date) -> tuple[Date, ...]: + _, last_day = monthrange(first_date.year, first_date.month) + current = Date(first_date.year, first_date.month, 1) + + while current.isoweekday() != first_date.isoweekday(): + current += timedelta(days=1) + + dates: list[Date] = [] + + while current.month == first_date.month and current.day <= last_day: + dates.append(current) + current += timedelta(days=7) + + return tuple(dates) From 03303a20e4f49a967b976d6de73639f37cfa2b3d Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Sun, 5 Jul 2026 15:03:35 +0200 Subject: [PATCH 03/23] Schreiben eines neuen Wunsches in die DB mit Post --- src/scheduling/api/app.py | 6 + src/scheduling/api/web/schemas.py | 4 +- src/scheduling/api/web/weeklyWishes_router.py | 177 ++++++++++++ src/scheduling/api/web/wishes_router.py | 267 ++++-------------- src/scheduling/timeoffice/facts.py | 14 +- src/scheduling/timeoffice/mapping/wishes.py | 69 +++++ src/scheduling/timeoffice/remapping/wishes.py | 103 +++++++ src/scheduling/timeoffice/service.py | 22 +- src/scheduling/timeoffice/writing/wishes.py | 247 ++++++++++++++++ 9 files changed, 689 insertions(+), 220 deletions(-) create mode 100644 src/scheduling/api/web/weeklyWishes_router.py create mode 100644 src/scheduling/timeoffice/remapping/wishes.py create mode 100644 src/scheduling/timeoffice/writing/wishes.py diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index 3eac03f5..6a382d61 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -9,6 +9,7 @@ from scheduling.api.solve.job_store import InMemorySolveJobStore from scheduling.api.solve.router import solve_router from scheduling.api.web.employee_router import employee_router +from scheduling.api.web.weeklyWishes_router import weeklyWishes_router from scheduling.api.web.wishes_router import wishes_router from scheduling.logging import configure_logging from scheduling.settings import get_settings @@ -19,6 +20,7 @@ from scheduling.timeoffice.reading.container import TimeOfficeReaders from scheduling.timeoffice.service import TimeOfficeService from scheduling.timeoffice.writing.solution import TimeOfficeSolutionWriter +from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter settings = get_settings() configure_logging(level=settings.log_level) @@ -39,6 +41,9 @@ 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, + ), ), solver_service=SolverService( settings=settings, @@ -60,6 +65,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # app.include_router(weights_router) # app.include_router(availability_router) app.include_router(wishes_router) +app.include_router(weeklyWishes_router) @app.get("/status") diff --git a/src/scheduling/api/web/schemas.py b/src/scheduling/api/web/schemas.py index 160eb5d6..44b2db8f 100644 --- a/src/scheduling/api/web/schemas.py +++ b/src/scheduling/api/web/schemas.py @@ -33,8 +33,8 @@ class WishesAndBlockedDatabaseRequest(SchedulingBaseModel): employees: tuple[WishesAndBlockedEmployeeRequest, ...] -class UpdateWishesAndBlockedRequest(SchedulingBaseModel): - data: WishesAndBlockedDatabaseRequest +class CreateWishesAndBlockedRequest(SchedulingBaseModel): + data: WishesAndBlockedEmployeeRequest class SuccessResponse(SchedulingBaseModel): diff --git a/src/scheduling/api/web/weeklyWishes_router.py b/src/scheduling/api/web/weeklyWishes_router.py new file mode 100644 index 00000000..a9bf6eaf --- /dev/null +++ b/src/scheduling/api/web/weeklyWishes_router.py @@ -0,0 +1,177 @@ +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.api.web.schemas import CreateWishesAndBlockedRequest, SuccessResponse, WishesAndBlockedDatabaseRequest +from scheduling.domain import Employee, PlanningMonth, WeeklyWish, WishType +from scheduling.timeoffice.facts import TIMEOFFICE_FACTS +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + +weeklyWishes_router = APIRouter() + + +@weeklyWishes_router.get("/global-wishes-and-blocked") +async def get_global_wishes_and_blocked( + planning_unit: int, + month: int, + year: int, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> dict[str, list[dict[str, Any]]]: + planning_month = PlanningMonth(year=year, month=month) + + dataset = timeoffice.fetch_dataset( + planning_unit_ids=(planning_unit,), + planning_month=planning_month, + ) + + return { + "employees": [ + _weekly_wishes_to_frontend( + employee=employee, + weekly_wishes=dataset.weekly_wishes, + ) + for employee in dataset.employees + ] + } + + +def _weekly_wishes_to_frontend( + *, + employee: Employee, + weekly_wishes: tuple[WeeklyWish, ...], +) -> dict[str, Any]: + name, firstname = _split_display_name(employee.display_name) + + employee_wishes = [wish for wish in weekly_wishes if wish.employee_id == employee.employee_id] + + return { + "key": employee.employee_id, + "firstname": firstname, + "name": name, + "wish_days": [wish.weekday for wish in employee_wishes if wish.type == WishType.PREFERRED_DAY], + "wish_shifts": [ + [wish.weekday, _weekly_wish_shift_to_frontend(wish)] + for wish in employee_wishes + if wish.type == WishType.PREFERRED_SHIFT + ], + "blocked_days": [wish.weekday for wish in employee_wishes if wish.type == WishType.FREE_DAY], + "blocked_shifts": [ + [wish.weekday, _weekly_wish_shift_to_frontend(wish)] + for wish in employee_wishes + if wish.type == WishType.FREE_SHIFT + ], + } + + +def _weekly_wish_shift_to_frontend(wish: WeeklyWish) -> str: + if wish.shift_id is None: + raise ValueError(f"{wish.type} weekly wish requires shift_id.") + + shift_fact = TIMEOFFICE_FACTS.reference_shift_facts_by_id.get(wish.shift_id) + + if shift_fact is None: + raise ValueError(f"Unknown reference shift for weekly wish: employee_id={wish.employee_id}") + + return shift_fact.expected_code + + +@weeklyWishes_router.put("/global-wishes-and-blocked") +async def put_global_wishes_and_blocked( + planning_unit: int, + month: int, + year: int, + request: CreateWishesAndBlockedRequest, +) -> SuccessResponse: + planning_month = PlanningMonth(year=year, month=month) + + weekly_wishes = _weekly_wishes_request_to_domain( + request=request.data, + planning_unit=planning_unit, + planning_month=planning_month, + ) + + logger.info( + "Received global wishes update: planning_unit=%s planning_month=%s weekly_wishes=%s", + planning_unit, + planning_month.label, + len(weekly_wishes), + ) + + # TODO: In Datenbank schreiben + + return SuccessResponse() + + +def _weekly_wishes_request_to_domain( + *, + request: WishesAndBlockedDatabaseRequest, + planning_unit: int, + planning_month: PlanningMonth, +) -> tuple[WeeklyWish, ...]: + weekly_wishes: list[WeeklyWish] = [] + + for employee in request.employees: + for weekday in employee.wish_days: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.PREFERRED_DAY, + ) + ) + + for weekday, shift_code in employee.wish_shifts: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.PREFERRED_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + for weekday in employee.blocked_days: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.FREE_DAY, + ) + ) + + for weekday, shift_code in employee.blocked_shifts: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.FREE_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + return tuple(weekly_wishes) + + +def _split_display_name(display_name: str) -> tuple[str, str]: + name, _separator, firstname = display_name.partition(" ") + return name, firstname + + +def _shift_id_from_frontend(shift_code: str) -> int: + 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 from wishes frontend: {shift_code!r}.") diff --git a/src/scheduling/api/web/wishes_router.py b/src/scheduling/api/web/wishes_router.py index a3ba2767..bf2006c4 100644 --- a/src/scheduling/api/web/wishes_router.py +++ b/src/scheduling/api/web/wishes_router.py @@ -5,8 +5,8 @@ from fastapi import APIRouter, Depends from scheduling.api.dependencies import get_timeoffice_service -from scheduling.api.web.schemas import SuccessResponse, UpdateWishesAndBlockedRequest, WishesAndBlockedDatabaseRequest -from scheduling.domain import Employee, PlanningMonth, WeeklyWish, Wish, WishType +from scheduling.api.web.schemas import CreateWishesAndBlockedRequest, SuccessResponse, WishesAndBlockedEmployeeRequest +from scheduling.domain import Employee, PlanningMonth, Wish, WishType from scheduling.timeoffice.facts import TIMEOFFICE_FACTS from scheduling.timeoffice.service import TimeOfficeService @@ -85,90 +85,81 @@ def _split_display_name(display_name: str) -> tuple[str, str]: return name, firstname -@wishes_router.put("/wishes-and-blocked") -async def put_wishes_and_blocked( +@wishes_router.post("/wishes-and-blocked") +async def create_wishes_and_blocked( planning_unit: int, month: int, year: int, - request: UpdateWishesAndBlockedRequest, + request: CreateWishesAndBlockedRequest, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], ) -> SuccessResponse: - month = PlanningMonth(year=year, month=month) + planning_month = PlanningMonth(year=year, month=month) - wishes = _wishes_request_to_domain( - request=request.data, + wishes = _wishes_employee_request_to_domain( + employee=request.data, planning_unit=planning_unit, - planning_month=month, + planning_month=planning_month, ) - logger.info( - "Received global wishes update: planning_unit=%s planning_month=%s weekly_wishes=%s", - planning_unit, - month, - len(wishes), + timeoffice.create_wishes( + planning_unit_id=planning_unit, + planning_month=planning_month, + employee_id=request.data.key, + wishes=wishes, ) - # TODO: In Datenbank schreiben - # timeoffice.update_wishes_and_blocked( - # planning_unit_id=planning_unit, - # planning_month=month, - # wishes=wishes, - # ) - - logger.info("Update wishes and blocked in database") - return SuccessResponse() -def _wishes_request_to_domain( +def _wishes_employee_request_to_domain( *, - request: WishesAndBlockedDatabaseRequest, + employee: WishesAndBlockedEmployeeRequest, planning_unit: int, planning_month: PlanningMonth, ) -> tuple[Wish, ...]: wishes: list[Wish] = [] - for employee in request.employees: - for day in employee.wish_days: - wishes.append( - Wish( - employee_id=employee.key, - planning_unit_id=planning_unit, - date=date(planning_month.year, planning_month.month, day), - type=WishType.PREFERRED_DAY, - ) + for day in employee.wish_days: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.PREFERRED_DAY, ) - - for day, shift_code in employee.wish_shifts: - wishes.append( - Wish( - employee_id=employee.key, - planning_unit_id=planning_unit, - date=date(planning_month.year, planning_month.month, day), - type=WishType.PREFERRED_SHIFT, - shift_id=_shift_id_from_frontend(shift_code), - ) + ) + + for day, shift_code in employee.wish_shifts: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.PREFERRED_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), ) - - for day in employee.blocked_days: - wishes.append( - Wish( - employee_id=employee.key, - planning_unit_id=planning_unit, - date=date(planning_month.year, planning_month.month, day), - type=WishType.FREE_DAY, - ) + ) + + for day in employee.blocked_days: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.FREE_DAY, ) - - for day, shift_code in employee.blocked_shifts: - wishes.append( - Wish( - employee_id=employee.key, - planning_unit_id=planning_unit, - date=date(planning_month.year, planning_month.month, day), - type=WishType.FREE_SHIFT, - shift_id=_shift_id_from_frontend(shift_code), - ) + ) + + for day, shift_code in employee.blocked_shifts: + wishes.append( + Wish( + employee_id=employee.key, + planning_unit_id=planning_unit, + date=date(planning_month.year, planning_month.month, day), + type=WishType.FREE_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), ) + ) return tuple(wishes) @@ -179,153 +170,3 @@ def _shift_id_from_frontend(shift_code: str) -> int: return shift_id raise ValueError(f"Unknown shift code from wishes frontend: {shift_code!r}.") - - -@wishes_router.get("/global-wishes-and-blocked") -async def get_global_wishes_and_blocked( - planning_unit: int, - month: int, - year: int, - timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], -) -> dict[str, list[dict[str, Any]]]: - planning_month = PlanningMonth(year=year, month=month) - - dataset = timeoffice.fetch_dataset( - planning_unit_ids=(planning_unit,), - planning_month=planning_month, - ) - - return { - "employees": [ - _weekly_wishes_to_frontend( - employee=employee, - weekly_wishes=dataset.weekly_wishes, - ) - for employee in dataset.employees - ] - } - - -def _weekly_wishes_to_frontend( - *, - employee: Employee, - weekly_wishes: tuple[WeeklyWish, ...], -) -> dict[str, Any]: - name, firstname = _split_display_name(employee.display_name) - - employee_wishes = [wish for wish in weekly_wishes if wish.employee_id == employee.employee_id] - - return { - "key": employee.employee_id, - "firstname": firstname, - "name": name, - "wish_days": [wish.weekday for wish in employee_wishes if wish.type == WishType.PREFERRED_DAY], - "wish_shifts": [ - [wish.weekday, _weekly_wish_shift_to_frontend(wish)] - for wish in employee_wishes - if wish.type == WishType.PREFERRED_SHIFT - ], - "blocked_days": [wish.weekday for wish in employee_wishes if wish.type == WishType.FREE_DAY], - "blocked_shifts": [ - [wish.weekday, _weekly_wish_shift_to_frontend(wish)] - for wish in employee_wishes - if wish.type == WishType.FREE_SHIFT - ], - } - - -def _weekly_wish_shift_to_frontend(wish: WeeklyWish) -> str: - if wish.shift_id is None: - raise ValueError(f"{wish.type} weekly wish requires shift_id.") - - shift_fact = TIMEOFFICE_FACTS.reference_shift_facts_by_id.get(wish.shift_id) - - if shift_fact is None: - raise ValueError(f"Unknown reference shift for weekly wish: employee_id={wish.employee_id}") - - return shift_fact.expected_code - - -@wishes_router.put("/global-wishes-and-blocked") -async def put_global_wishes_and_blocked( - planning_unit: int, - month: int, - year: int, - request: UpdateWishesAndBlockedRequest, -) -> SuccessResponse: - planning_month = PlanningMonth(year=year, month=month) - - weekly_wishes = _weekly_wishes_request_to_domain( - request=request.data, - planning_unit=planning_unit, - planning_month=planning_month, - ) - - logger.info( - "Received global wishes update: planning_unit=%s planning_month=%s weekly_wishes=%s", - planning_unit, - planning_month.label, - len(weekly_wishes), - ) - - # TODO: In Datenbank schreiben - - return SuccessResponse() - - -def _weekly_wishes_request_to_domain( - *, - request: WishesAndBlockedDatabaseRequest, - planning_unit: int, - planning_month: PlanningMonth, -) -> tuple[WeeklyWish, ...]: - weekly_wishes: list[WeeklyWish] = [] - - for employee in request.employees: - for weekday in employee.wish_days: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.PREFERRED_DAY, - ) - ) - - for weekday, shift_code in employee.wish_shifts: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.PREFERRED_SHIFT, - shift_id=_shift_id_from_frontend(shift_code), - ) - ) - - for weekday in employee.blocked_days: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.FREE_DAY, - ) - ) - - for weekday, shift_code in employee.blocked_shifts: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.FREE_SHIFT, - shift_id=_shift_id_from_frontend(shift_code), - ) - ) - - return tuple(weekly_wishes) diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index 12e0e7f5..53216d1f 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -79,6 +79,7 @@ class TimeOfficeFacts: work_shift_type_id: int reference_shift_facts_by_id: Mapping[ShiftId, TimeOfficeReferenceShiftFact] + wish_absence_shift_id_by_type: Mapping[WishType, int] # Non-reference source shift IDs normalized to reduced reference shifts. # Missing source shift ID => fail loudly in mapping. @@ -105,22 +106,22 @@ class TimeOfficeFacts: REFERENCE_SHIFT_FACTS_BY_ID: Mapping[ShiftId, TimeOfficeReferenceShiftFact] = MappingProxyType( { EARLY_F2_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="F", + expected_code="F2_", type=ShiftType.EARLY, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, ), LATE_S2_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="S", + expected_code="S2_", type=ShiftType.LATE, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, ), NIGHT_N2_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="N", + expected_code="N2_", type=ShiftType.NIGHT, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, ), INTERMEDIATE_T75_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="T", + expected_code="T75_", type=ShiftType.INTERMEDIATE, staffing_role=StaffingDemandRole.OPTIONAL_COVERAGE, ), @@ -291,6 +292,11 @@ class TimeOfficeFacts: "FR": WishType.FREE_DAY, } ), + wish_absence_shift_id_by_type=MappingProxyType( + { + WishType.FREE_DAY: 1089, + } + ), monthly_target_work_account_id=MONTHLY_TARGET_WORK_ACCOUNT_ID, monthly_actual_work_account_id=MONTHLY_ACTUAL_WORK_ACCOUNT_ID, ) diff --git a/src/scheduling/timeoffice/mapping/wishes.py b/src/scheduling/timeoffice/mapping/wishes.py index 53b3672d..1c7644af 100644 --- a/src/scheduling/timeoffice/mapping/wishes.py +++ b/src/scheduling/timeoffice/mapping/wishes.py @@ -5,6 +5,7 @@ from datetime import timedelta from scheduling.domain import PlanningMonth, Shift, WeeklyWish, Wish, WishType +from scheduling.domain.shift import ShiftType from scheduling.timeoffice.facts import TimeOfficeFacts from scheduling.timeoffice.mapping.shifts import reference_shift_id_for_source_shift from scheduling.timeoffice.reading.wishes import TimeOfficeWishRow @@ -34,6 +35,7 @@ def map_wishes( ) wishes = _deduplicate_wishes(wishes) + wishes = _collapse_preferred_day_wishes(wishes, facts=facts) return _split_weekly_repeated_wishes(wishes) @@ -170,6 +172,73 @@ def _deduplicate_wishes(wishes: tuple[Wish, ...]) -> tuple[Wish, ...]: ) +PREFERRED_DAY_SHIFT_TYPES = { + ShiftType.EARLY, + ShiftType.LATE, + ShiftType.NIGHT, +} + + +def _collapse_preferred_day_wishes( + wishes: tuple[Wish, ...], + *, + facts: TimeOfficeFacts, +) -> tuple[Wish, ...]: + preferred_day_shift_ids = _preferred_day_shift_ids(facts) + + wishes_by_day: dict[tuple[int, int, Date], list[Wish]] = defaultdict(list) + + for wish in wishes: + key = (wish.employee_id, wish.planning_unit_id, wish.date) + wishes_by_day[key].append(wish) + + collapsed_wishes: list[Wish] = [] + + for day_wishes in wishes_by_day.values(): + preferred_shift_ids = {wish.shift_id for wish in day_wishes if wish.type == WishType.PREFERRED_SHIFT} + + has_preferred_day = preferred_day_shift_ids.issubset(preferred_shift_ids) + + if not has_preferred_day: + collapsed_wishes.extend(day_wishes) + continue + + first_wish = day_wishes[0] + + collapsed_wishes.append( + Wish( + employee_id=first_wish.employee_id, + planning_unit_id=first_wish.planning_unit_id, + date=first_wish.date, + type=WishType.PREFERRED_DAY, + ) + ) + + collapsed_wishes.extend( + wish + for wish in day_wishes + if not (wish.type == WishType.PREFERRED_SHIFT and wish.shift_id in preferred_day_shift_ids) + ) + + return _deduplicate_wishes(tuple(collapsed_wishes)) + + +def _preferred_day_shift_ids(facts: TimeOfficeFacts) -> frozenset[int]: + shift_ids = frozenset( + shift_id + for shift_id, shift_fact in facts.reference_shift_facts_by_id.items() + if shift_fact.type in PREFERRED_DAY_SHIFT_TYPES + ) + + if len(shift_ids) != 3: + raise ValueError( + "Expected exactly three TimeOffice reference shifts for preferred day " + f"mapping, got shift_ids={sorted(shift_ids)}." + ) + + return shift_ids + + def _split_weekly_repeated_wishes(wishes: tuple[Wish, ...]) -> MappedWishes: wishes_by_key: dict[tuple[int, int, WishType, int | None, int], list[Wish]] = defaultdict(list) diff --git a/src/scheduling/timeoffice/remapping/wishes.py b/src/scheduling/timeoffice/remapping/wishes.py new file mode 100644 index 00000000..270ec227 --- /dev/null +++ b/src/scheduling/timeoffice/remapping/wishes.py @@ -0,0 +1,103 @@ +from datetime import date as Date + +from scheduling.domain import SchedulingBaseModel, Wish, WishType +from scheduling.timeoffice.facts import TimeOfficeFacts + + +class TimeOfficeWishWriteRow(SchedulingBaseModel): + employee_id: int + planning_unit_id: int + plan_id: int + wish_date: Date + work_shift_id: int | None = None + absence_shift_id: int | None = None + + +PREFERRED_DAY_SHIFT_CODES = ("F", "S", "N") + + +def map_wishes_to_timeoffice_rows( + *, + wishes: tuple[Wish, ...], + plan_id: int, + facts: TimeOfficeFacts, +) -> tuple[TimeOfficeWishWriteRow, ...]: + return tuple( + row + for wish in wishes + for row in _map_wish_to_timeoffice_rows( + wish=wish, + plan_id=plan_id, + facts=facts, + ) + ) + + +def _map_wish_to_timeoffice_rows( + *, + wish: Wish, + plan_id: int, + facts: TimeOfficeFacts, +) -> tuple[TimeOfficeWishWriteRow, ...]: + if wish.type == WishType.PREFERRED_DAY: + return tuple( + TimeOfficeWishWriteRow( + employee_id=wish.employee_id, + planning_unit_id=wish.planning_unit_id, + plan_id=plan_id, + wish_date=wish.date, + work_shift_id=shift_id, + ) + for shift_id in _preferred_day_shift_ids(facts) + ) + + if wish.type == WishType.PREFERRED_SHIFT: + return ( + TimeOfficeWishWriteRow( + employee_id=wish.employee_id, + planning_unit_id=wish.planning_unit_id, + plan_id=plan_id, + wish_date=wish.date, + work_shift_id=_require_shift_id(wish), + ), + ) + + if wish.type == WishType.FREE_DAY: + return ( + TimeOfficeWishWriteRow( + employee_id=wish.employee_id, + planning_unit_id=wish.planning_unit_id, + plan_id=plan_id, + wish_date=wish.date, + absence_shift_id=_absence_shift_id_for_wish_type(wish.type, facts=facts), + ), + ) + + if wish.type == WishType.FREE_SHIFT: + raise ValueError("FREE_SHIFT cannot be written to TimeOffice yet. ") + + raise ValueError(f"Unsupported wish type: {wish.type}") + + +def _preferred_day_shift_ids(facts: TimeOfficeFacts) -> tuple[int, ...]: + shift_id_by_code = { + shift_fact.expected_code: shift_id for shift_id, shift_fact in facts.reference_shift_facts_by_id.items() + } + + return tuple(shift_id_by_code[code] for code in PREFERRED_DAY_SHIFT_CODES) + + +def _require_shift_id(wish: Wish) -> int: + if wish.shift_id is None: + raise ValueError(f"{wish.type} wish requires shift_id.") + + return wish.shift_id + + +def _absence_shift_id_for_wish_type(wish_type: WishType, *, facts: TimeOfficeFacts) -> int: + absence_shift_id = facts.wish_absence_shift_id_by_type.get(wish_type) + + if absence_shift_id is None: + raise ValueError(f"No TimeOffice absence shift configured for wish_type={wish_type}.") + + return absence_shift_id diff --git a/src/scheduling/timeoffice/service.py b/src/scheduling/timeoffice/service.py index 54790290..05edd8f4 100644 --- a/src/scheduling/timeoffice/service.py +++ b/src/scheduling/timeoffice/service.py @@ -3,13 +3,14 @@ from sqlalchemy import Engine from scheduling.api.solve.schemas import SolveOptions -from scheduling.domain import PlanningMonth, SchedulingDataset +from scheduling.domain import PlanningMonth, SchedulingDataset, Wish from scheduling.solver.models import Solution from scheduling.timeoffice.facts import TimeOfficeFacts 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 LegacySolutionExportPaths, TimeOfficeSolutionWriter +from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter from scheduling.validation import validate_scheduling_dataset logger = logging.getLogger(__name__) @@ -25,11 +26,13 @@ def __init__( engine: Engine, readers: TimeOfficeReaders, solution_writer: TimeOfficeSolutionWriter, + wish_writer: TimeOfficeWishWriter, ) -> None: self._facts = facts self._engine = engine self._readers = readers self._solution_writer = solution_writer + self._wish_writer = wish_writer def get_solve_options(self) -> SolveOptions: logger.info("Fetching TimeOffice solve options") @@ -132,3 +135,20 @@ def _normalize_planning_unit_ids( ) return normalized + + def create_wishes( + self, + *, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + wishes: tuple[Wish, ...], + ) -> None: + with self._engine.begin() as connection: + self._wish_writer.insert_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + wishes=wishes, + facts=self._facts, + ) diff --git a/src/scheduling/timeoffice/writing/wishes.py b/src/scheduling/timeoffice/writing/wishes.py new file mode 100644 index 00000000..7a345b31 --- /dev/null +++ b/src/scheduling/timeoffice/writing/wishes.py @@ -0,0 +1,247 @@ +from sqlalchemy import Connection, text + +from scheduling.domain import PlanningMonth, Wish +from scheduling.timeoffice.facts import TimeOfficeFacts +from scheduling.timeoffice.remapping.wishes import TimeOfficeWishWriteRow, map_wishes_to_timeoffice_rows + + +class TimeOfficeWishWriter: + def __init__(self, *, target_planning_status_id: int) -> None: + self._target_planning_status_id = target_planning_status_id + + def insert_wishes( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + wishes: tuple[Wish, ...], + facts: TimeOfficeFacts, + ) -> None: + if not wishes: + return + + plan_id = self._find_target_plan_id( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + ) + + rows = map_wishes_to_timeoffice_rows( + wishes=wishes, + plan_id=plan_id, + facts=facts, + ) + + self._insert_rows(connection=connection, rows=rows) + + def _find_target_plan_id( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + ) -> int: + query = text( + """ + SELECT TOP 1 + pkg.RefPlan AS plan_id, + COUNT(*) AS row_count + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPlanungseinheiten = :planning_unit_id + AND CONVERT(date, pkg.Datum) BETWEEN :start AND :end + GROUP BY pkg.RefPlan + ORDER BY row_count DESC + """ + ) + + row = ( + connection.execute( + query, + { + "planning_unit_id": planning_unit_id, + "start": planning_month.start, + "end": planning_month.end, + }, + ) + .mappings() + .first() + ) + + if row is None: + raise ValueError( + "No TimeOffice target plan found for wishes: " + f"planning_unit_id={planning_unit_id} " + f"planning_month={planning_month.label}." + ) + + return int(row["plan_id"]) + + def _insert_rows( + self, + *, + connection: Connection, + rows: tuple[TimeOfficeWishWriteRow, ...], + ) -> None: + if not rows: + return + + query = text( + """ + INSERT INTO TPlanPersonalKommtGeht ( + RefPlan, + RefPersonal, + Datum, + RefStati, + lfdNr, + RefgAbw, + RefDienste, + RefBerufe, + RefPlanungseinheiten, + VonZeit, + BisZeit, + RefDienstAbw, + Minuten, + Info, + RefEinsatzArten, + Wunschdienst, + BereitVon, + BereitBis + ) + VALUES ( + :plan_id, + :employee_id, + :wish_date, + :status_id, + :sequence_number, + NULL, + :work_shift_id, + :profession_id, + :planning_unit_id, + NULL, + NULL, + :absence_shift_id, + 0, + NULL, + NULL, + 1, + NULL, + NULL + ) + """ + ) + + sequence_numbers: dict[tuple[int, int, object], int] = {} + + def next_sequence_number(row: TimeOfficeWishWriteRow) -> int: + key = (row.plan_id, row.employee_id, row.wish_date) + + if key not in sequence_numbers: + sequence_numbers[key] = self._next_sequence_number( + connection=connection, + plan_id=row.plan_id, + employee_id=row.employee_id, + wish_date=row.wish_date, + ) + + sequence_number = sequence_numbers[key] + sequence_numbers[key] += 1 + return sequence_number + + connection.execute( + query, + [ + { + "plan_id": row.plan_id, + "employee_id": row.employee_id, + "wish_date": row.wish_date, + "status_id": self._target_planning_status_id, + "sequence_number": next_sequence_number(row), + "work_shift_id": row.work_shift_id, + "profession_id": self._find_profession_id( + connection=connection, + employee_id=row.employee_id, + planning_unit_id=row.planning_unit_id, + ), + "planning_unit_id": row.planning_unit_id, + "absence_shift_id": row.absence_shift_id, + } + for row in rows + ], + ) + + def _next_sequence_number( + self, + *, + connection: Connection, + plan_id: int, + employee_id: int, + wish_date: object, + ) -> int: + query = text( + """ + SELECT + COALESCE(MAX(pkg.lfdNr), 0) + 1 AS next_sequence_number + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPlan = :plan_id + AND pkg.RefPersonal = :employee_id + AND CONVERT(date, pkg.Datum) = :wish_date + """ + ) + + row = ( + connection.execute( + query, + { + "plan_id": plan_id, + "employee_id": employee_id, + "wish_date": wish_date, + }, + ) + .mappings() + .one() + ) + + return int(row["next_sequence_number"]) + + def _find_profession_id( + self, + *, + connection: Connection, + employee_id: int, + planning_unit_id: int, + ) -> int: + query = text( + """ + SELECT TOP 1 + pkg.RefBerufe AS profession_id, + COUNT(*) AS usage_count + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPersonal = :employee_id + AND pkg.RefPlanungseinheiten = :planning_unit_id + AND pkg.RefBerufe IS NOT NULL + GROUP BY pkg.RefBerufe + ORDER BY usage_count DESC + """ + ) + + row = ( + connection.execute( + query, + { + "employee_id": employee_id, + "planning_unit_id": planning_unit_id, + }, + ) + .mappings() + .first() + ) + + if row is None: + raise ValueError( + "No TimeOffice profession found for employee wishes: " + f"employee_id={employee_id} " + f"planning_unit_id={planning_unit_id}." + ) + + return int(row["profession_id"]) From 3ffa1fd2011c469d3d400bdc2538101c0973f617 Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Sun, 5 Jul 2026 17:03:19 +0200 Subject: [PATCH 04/23] =?UTF-8?q?Schreiben,=20l=C3=B6schen=20und=20ver?= =?UTF-8?q?=C3=A4ndern=20von=20W=C3=BCnschen=20=C3=BCber=20API=20in=20der?= =?UTF-8?q?=20Datenbank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scheduling/api/web/wishes_router.py | 31 ++++++++++++-- src/scheduling/timeoffice/mapping/wishes.py | 41 +++++++++++++++++++ src/scheduling/timeoffice/reading/wishes.py | 4 +- src/scheduling/timeoffice/remapping/wishes.py | 13 +++++- src/scheduling/timeoffice/service.py | 24 ++++++++++- src/scheduling/timeoffice/writing/wishes.py | 41 +++++++++++++++++++ 6 files changed, 145 insertions(+), 9 deletions(-) diff --git a/src/scheduling/api/web/wishes_router.py b/src/scheduling/api/web/wishes_router.py index bf2006c4..cb1b9d75 100644 --- a/src/scheduling/api/web/wishes_router.py +++ b/src/scheduling/api/web/wishes_router.py @@ -85,14 +85,18 @@ def _split_display_name(display_name: str) -> tuple[str, str]: return name, firstname -@wishes_router.post("/wishes-and-blocked") -async def create_wishes_and_blocked( +@wishes_router.put("/wishes-and-blocked/{employee_id}") +async def replace_wishes_and_blocked( + employee_id: int, planning_unit: int, month: int, year: int, request: CreateWishesAndBlockedRequest, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], ) -> SuccessResponse: + if request.data.key != employee_id: + raise ValueError("employee_id path parameter does not match request.data.key.") + planning_month = PlanningMonth(year=year, month=month) wishes = _wishes_employee_request_to_domain( @@ -101,10 +105,10 @@ async def create_wishes_and_blocked( planning_month=planning_month, ) - timeoffice.create_wishes( + timeoffice.replace_wishes( planning_unit_id=planning_unit, planning_month=planning_month, - employee_id=request.data.key, + employee_id=employee_id, wishes=wishes, ) @@ -170,3 +174,22 @@ def _shift_id_from_frontend(shift_code: str) -> int: return shift_id raise ValueError(f"Unknown shift code from wishes frontend: {shift_code!r}.") + + +@wishes_router.delete("/wishes-and-blocked/{employee_id}") +async def delete_wishes_and_blocked( + employee_id: int, + planning_unit: int, + month: int, + year: int, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> SuccessResponse: + planning_month = PlanningMonth(year=year, month=month) + + timeoffice.delete_employee_wishes( + planning_unit_id=planning_unit, + planning_month=planning_month, + employee_id=employee_id, + ) + + return SuccessResponse() diff --git a/src/scheduling/timeoffice/mapping/wishes.py b/src/scheduling/timeoffice/mapping/wishes.py index 1c7644af..ec868c6e 100644 --- a/src/scheduling/timeoffice/mapping/wishes.py +++ b/src/scheduling/timeoffice/mapping/wishes.py @@ -46,6 +46,13 @@ def _map_wish( known_shift_ids: set[int], facts: TimeOfficeFacts, ) -> Wish: + if row.work_shift_id is not None and _has_absence(row): + return _map_free_shift_wish( + row=row, + known_shift_ids=known_shift_ids, + facts=facts, + ) + if row.work_shift_id is not None: return _map_preferred_shift_wish( row=row, @@ -56,6 +63,40 @@ def _map_wish( return _map_absence_wish(row=row, facts=facts) +def _has_absence(row: TimeOfficeWishRow) -> bool: + return row.global_absence_shift_id is not None or row.absence_shift_id is not None + + +def _map_free_shift_wish( + *, + row: TimeOfficeWishRow, + known_shift_ids: set[int], + facts: TimeOfficeFacts, +) -> Wish: + reference_shift_id = reference_shift_id_for_source_shift( + source_shift_id=row.work_shift_id, + source_shift_code=row.work_shift_code, + facts=facts, + context=f"TimeOffice free-shift wish employee_id={row.employee_id} date={row.wish_date.date()}", + ) + + if reference_shift_id not in known_shift_ids: + raise ValueError( + "TimeOffice free-shift wish references shift that is not part " + "of the mapped SchedulingDataset: " + f"source_shift_id={row.work_shift_id} " + f"reference_shift_id={reference_shift_id}." + ) + + return Wish( + employee_id=row.employee_id, + planning_unit_id=row.planning_unit_id, + date=row.wish_date.date(), + type=WishType.FREE_SHIFT, + shift_id=reference_shift_id, + ) + + def _map_preferred_shift_wish( *, row: TimeOfficeWishRow, diff --git a/src/scheduling/timeoffice/reading/wishes.py b/src/scheduling/timeoffice/reading/wishes.py index 5ff31745..0b860161 100644 --- a/src/scheduling/timeoffice/reading/wishes.py +++ b/src/scheduling/timeoffice/reading/wishes.py @@ -29,7 +29,7 @@ class TimeOfficeWishRow(TimeOfficeSourceRow): resolved_absence_shift_id: SourceNullableInt = None resolved_absence_code: CleanNullableText = None resolved_absence_name: CleanNullableText = None - + """ @model_validator(mode="after") def validate_row_type(self) -> Self: has_work_shift = self.work_shift_id is not None @@ -47,7 +47,7 @@ def validate_row_type(self) -> Self: f"for employee_id={self.employee_id}, wish_date={self.wish_date}." ) - return self + return self""" @model_validator(mode="after") def validate_absence_references(self) -> Self: diff --git a/src/scheduling/timeoffice/remapping/wishes.py b/src/scheduling/timeoffice/remapping/wishes.py index 270ec227..51c2dde0 100644 --- a/src/scheduling/timeoffice/remapping/wishes.py +++ b/src/scheduling/timeoffice/remapping/wishes.py @@ -13,7 +13,7 @@ class TimeOfficeWishWriteRow(SchedulingBaseModel): absence_shift_id: int | None = None -PREFERRED_DAY_SHIFT_CODES = ("F", "S", "N") +PREFERRED_DAY_SHIFT_CODES = ("F2_", "S2_", "N2_") def map_wishes_to_timeoffice_rows( @@ -74,7 +74,16 @@ def _map_wish_to_timeoffice_rows( ) if wish.type == WishType.FREE_SHIFT: - raise ValueError("FREE_SHIFT cannot be written to TimeOffice yet. ") + return ( + TimeOfficeWishWriteRow( + employee_id=wish.employee_id, + planning_unit_id=wish.planning_unit_id, + plan_id=plan_id, + wish_date=wish.date, + work_shift_id=_require_shift_id(wish), + absence_shift_id=_absence_shift_id_for_wish_type(WishType.FREE_DAY, facts=facts), + ), + ) raise ValueError(f"Unsupported wish type: {wish.type}") diff --git a/src/scheduling/timeoffice/service.py b/src/scheduling/timeoffice/service.py index 05edd8f4..886dde2a 100644 --- a/src/scheduling/timeoffice/service.py +++ b/src/scheduling/timeoffice/service.py @@ -136,7 +136,7 @@ def _normalize_planning_unit_ids( return normalized - def create_wishes( + def replace_wishes( self, *, planning_unit_id: int, @@ -145,6 +145,13 @@ def create_wishes( wishes: tuple[Wish, ...], ) -> None: with self._engine.begin() as connection: + self._wish_writer.delete_employee_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) + self._wish_writer.insert_wishes( connection=connection, planning_unit_id=planning_unit_id, @@ -152,3 +159,18 @@ def create_wishes( wishes=wishes, facts=self._facts, ) + + def delete_employee_wishes( + self, + *, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + ) -> None: + with self._engine.begin() as connection: + self._wish_writer.delete_employee_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) diff --git a/src/scheduling/timeoffice/writing/wishes.py b/src/scheduling/timeoffice/writing/wishes.py index 7a345b31..238ce594 100644 --- a/src/scheduling/timeoffice/writing/wishes.py +++ b/src/scheduling/timeoffice/writing/wishes.py @@ -245,3 +245,44 @@ def _find_profession_id( ) return int(row["profession_id"]) + + def delete_employee_wishes( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + ) -> None: + plan_id = self._find_target_plan_id( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + ) + + query = text( + """ + DELETE FROM TPlanPersonalKommtGeht + WHERE RefPlan = :plan_id + AND RefPersonal = :employee_id + AND RefPlanungseinheiten = :planning_unit_id + AND CONVERT(date, Datum) BETWEEN :start AND :end + AND ISNULL(Wunschdienst, 0) <> 0 + AND ( + RefDienste IS NOT NULL + OR RefgAbw IS NOT NULL + OR RefDienstAbw IS NOT NULL + ) + """ + ) + + connection.execute( + query, + { + "plan_id": plan_id, + "employee_id": employee_id, + "planning_unit_id": planning_unit_id, + "start": planning_month.start, + "end": planning_month.end, + }, + ) From a24c2b3fcf01c333133a59c75b35cdbacec3b53a Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 5 Jul 2026 18:33:39 +0200 Subject: [PATCH 05/23] minimal staff boilerplate --- src/scheduling/api/web/minimal_staff_router.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/scheduling/api/web/minimal_staff_router.py b/src/scheduling/api/web/minimal_staff_router.py index e69de29b..67df74fb 100644 --- a/src/scheduling/api/web/minimal_staff_router.py +++ b/src/scheduling/api/web/minimal_staff_router.py @@ -0,0 +1,8 @@ +import logging + +from fastapi import APIRouter + +logger = logging.getLogger(__name__) + + +minimal_staff_router = APIRouter() From 4aefea2080b514a830bd64bb9f6febcfd5b8925d Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 5 Jul 2026 20:09:11 +0200 Subject: [PATCH 06/23] minimum staff api infra --- src/scheduling/api/app.py | 2 ++ .../api/web/minimal_staff_router.py | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index 6a382d61..189fca8b 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -9,6 +9,7 @@ from scheduling.api.solve.job_store import InMemorySolveJobStore from scheduling.api.solve.router import solve_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.weeklyWishes_router import weeklyWishes_router from scheduling.api.web.wishes_router import wishes_router from scheduling.logging import configure_logging @@ -66,6 +67,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # app.include_router(availability_router) app.include_router(wishes_router) app.include_router(weeklyWishes_router) +app.include_router(minimal_staff_router) @app.get("/status") diff --git a/src/scheduling/api/web/minimal_staff_router.py b/src/scheduling/api/web/minimal_staff_router.py index 67df74fb..aee9d1ea 100644 --- a/src/scheduling/api/web/minimal_staff_router.py +++ b/src/scheduling/api/web/minimal_staff_router.py @@ -1,8 +1,26 @@ import logging +from datetime import date +from typing import Annotated, Any -from fastapi import APIRouter +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.domain import PlanningMonth +from scheduling.timeoffice.service import TimeOfficeService logger = logging.getLogger(__name__) minimal_staff_router = APIRouter() + + +@minimal_staff_router.get("/minimal-staff") +async def get_minimal_staff_func( + planning_unit: int, from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)] +) -> Any: + """Return minimal staff requirements for a planning unit and month.""" + + 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)) + return dataset From 0e1fc0c03d8c6a7d6ff520c612eff886f816b594 Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 5 Jul 2026 20:25:14 +0200 Subject: [PATCH 07/23] minimal_staff draft --- .../api/web/minimal_staff_router.py | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/scheduling/api/web/minimal_staff_router.py b/src/scheduling/api/web/minimal_staff_router.py index aee9d1ea..7ab764b9 100644 --- a/src/scheduling/api/web/minimal_staff_router.py +++ b/src/scheduling/api/web/minimal_staff_router.py @@ -19,8 +19,75 @@ async def get_minimal_staff_func( planning_unit: int, from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)] ) -> Any: """Return minimal staff requirements for a planning unit and month.""" - 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)) - return dataset + return _generate_minimal_staff_requirements(dataset) + + +def _generate_minimal_staff_requirements(dataset: Any) -> dict[str, dict[str, dict[str, int]]]: + level_map = {"trainee": "Azubi", "professional": "Fachkraft", "assistant": "Hilfskraft"} + + shift_map = {"early": "F", "late": "S", "night": "N"} + + days_of_week = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + + output = {de_level: {day: {"F": 0, "N": 0, "S": 0} for day in days_of_week} for de_level in level_map.values()} + + if isinstance(dataset, dict): + demand_data = dataset.get("demand_requirements", []) + else: + demand_data = getattr(dataset, "demand_requirements", []) + + if demand_data: + for req in demand_data: + if isinstance(req, dict): + staff_level = req.get("staff_level") + shift_type = req.get("shift_type") + day_of_week = req.get("day_of_week") + min_required = req.get("min_required", 0) + else: + staff_level = getattr(req, "staff_level", None) + shift_type = getattr(req, "shift_type", None) + day_of_week = getattr(req, "day_of_week", None) + min_required = getattr(req, "min_required", 0) + + lvl = level_map.get(str(staff_level)) + shift = shift_map.get(str(shift_type)) + day = str(day_of_week) + + if lvl and shift and day in days_of_week: + output[lvl][day][shift] = min_required + else: + # Default data + output = { + "Azubi": { + "Di": {"F": 1, "N": 0, "S": 1}, + "Do": {"F": 1, "N": 0, "S": 1}, + "Fr": {"F": 1, "N": 0, "S": 1}, + "Mi": {"F": 1, "N": 0, "S": 1}, + "Mo": {"F": 1, "N": 0, "S": 1}, + "Sa": {"F": 1, "N": 0, "S": 1}, + "So": {"F": 1, "N": 0, "S": 1}, + }, + "Fachkraft": { + "Di": {"F": 3, "N": 2, "S": 2}, + "Do": {"F": 3, "N": 2, "S": 2}, + "Fr": {"F": 3, "N": 2, "S": 2}, + "Mi": {"F": 4, "N": 2, "S": 2}, + "Mo": {"F": 3, "N": 2, "S": 2}, + "Sa": {"F": 2, "N": 1, "S": 2}, + "So": {"F": 2, "N": 1, "S": 2}, + }, + "Hilfskraft": { + "Di": {"F": 2, "N": 0, "S": 2}, + "Do": {"F": 2, "N": 0, "S": 2}, + "Fr": {"F": 2, "N": 0, "S": 2}, + "Mi": {"F": 2, "N": 0, "S": 2}, + "Mo": {"F": 2, "N": 0, "S": 2}, + "Sa": {"F": 2, "N": 1, "S": 2}, + "So": {"F": 2, "N": 1, "S": 2}, + }, + } + + return output From 207970a5e3f7062f7ed72a510b1169f8fc428bc7 Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 5 Jul 2026 20:53:09 +0200 Subject: [PATCH 08/23] added put outline --- .../api/web/minimal_staff_router.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/scheduling/api/web/minimal_staff_router.py b/src/scheduling/api/web/minimal_staff_router.py index 7ab764b9..d1ece581 100644 --- a/src/scheduling/api/web/minimal_staff_router.py +++ b/src/scheduling/api/web/minimal_staff_router.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends from scheduling.api.dependencies import get_timeoffice_service +from scheduling.api.web.schemas import SuccessResponse from scheduling.domain import PlanningMonth from scheduling.timeoffice.service import TimeOfficeService @@ -91,3 +92,25 @@ def _generate_minimal_staff_requirements(dataset: Any) -> dict[str, dict[str, di } return output + + +# TODO: minimal_staff put missing, unsure where in DB to write to +@minimal_staff_router.put("/minimal-staff") +async def put_minimal_staff( + planning_unit: int, + from_date: date, + request: dict[str, dict[str, int]], +) -> dict[str, bool]: + month = PlanningMonth(year=from_date.year, month=from_date.month) + request_json = request.get("data", {}) + + logger.info( + "Received minimal staff update: planning_unit=%s planning_month=%s minimal_staff=%s", + planning_unit, + month.label, + request_json, + ) + + logger.info("Update availability in database") + + return SuccessResponse() From 20a7955c13722f71d6da6b7ebaad37c463856c3d Mon Sep 17 00:00:00 2001 From: Fengwu Lu Date: Sun, 5 Jul 2026 22:35:18 +0200 Subject: [PATCH 09/23] update wishes and blocked api parameter --- src/scheduling/api/web/weeklyWishes_router.py | 13 +++++++------ src/scheduling/api/web/wishes_router.py | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/scheduling/api/web/weeklyWishes_router.py b/src/scheduling/api/web/weeklyWishes_router.py index a9bf6eaf..921674a5 100644 --- a/src/scheduling/api/web/weeklyWishes_router.py +++ b/src/scheduling/api/web/weeklyWishes_router.py @@ -1,4 +1,5 @@ import logging +from datetime import date from typing import Annotated, Any from fastapi import APIRouter, Depends @@ -17,11 +18,11 @@ @weeklyWishes_router.get("/global-wishes-and-blocked") async def get_global_wishes_and_blocked( planning_unit: int, - month: int, - year: int, + from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], ) -> dict[str, list[dict[str, Any]]]: - planning_month = PlanningMonth(year=year, month=month) + from_date_year, from_date_month = from_date.year, from_date.month + planning_month = PlanningMonth(year=from_date_year, month=from_date_month) dataset = timeoffice.fetch_dataset( planning_unit_ids=(planning_unit,), @@ -82,11 +83,11 @@ def _weekly_wish_shift_to_frontend(wish: WeeklyWish) -> str: @weeklyWishes_router.put("/global-wishes-and-blocked") async def put_global_wishes_and_blocked( planning_unit: int, - month: int, - year: int, + from_date: date, request: CreateWishesAndBlockedRequest, ) -> SuccessResponse: - planning_month = PlanningMonth(year=year, month=month) + from_date_year, from_date_month = from_date.year, from_date.month + planning_month = PlanningMonth(year=from_date_year, month=from_date_month) weekly_wishes = _weekly_wishes_request_to_domain( request=request.data, diff --git a/src/scheduling/api/web/wishes_router.py b/src/scheduling/api/web/wishes_router.py index cb1b9d75..b4b50066 100644 --- a/src/scheduling/api/web/wishes_router.py +++ b/src/scheduling/api/web/wishes_router.py @@ -18,11 +18,11 @@ @wishes_router.get("/wishes-and-blocked") async def get_wishes_and_blocked( planning_unit: int, - month: int, - year: int, + from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], ) -> dict[str, list[dict[str, Any]]]: - month = PlanningMonth(year=year, month=month) + from_date_year, from_date_month = from_date.year, from_date.month + month = PlanningMonth(year=from_date_year, month=from_date_month) dataset = timeoffice.fetch_dataset( planning_unit_ids=(planning_unit,), From 062a613a31d80345f05ea528d759e8f188de37cb Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Mon, 6 Jul 2026 19:15:28 +0200 Subject: [PATCH 10/23] =?UTF-8?q?Speichern,=20=C3=84ndern=20und=20Lesen=20?= =?UTF-8?q?von=20Globalen=20W=C3=BCnschen=20in=20der=20Datenbank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scheduling/api/web/weeklyWishes_router.py | 132 ++++++++++-------- src/scheduling/timeoffice/remapping/wishes.py | 47 ++++++- src/scheduling/timeoffice/service.py | 29 +++- 3 files changed, 151 insertions(+), 57 deletions(-) diff --git a/src/scheduling/api/web/weeklyWishes_router.py b/src/scheduling/api/web/weeklyWishes_router.py index 921674a5..7bb4e938 100644 --- a/src/scheduling/api/web/weeklyWishes_router.py +++ b/src/scheduling/api/web/weeklyWishes_router.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends from scheduling.api.dependencies import get_timeoffice_service -from scheduling.api.web.schemas import CreateWishesAndBlockedRequest, SuccessResponse, WishesAndBlockedDatabaseRequest +from scheduling.api.web.schemas import CreateWishesAndBlockedRequest, SuccessResponse, WishesAndBlockedEmployeeRequest from scheduling.domain import Employee, PlanningMonth, WeeklyWish, WishType from scheduling.timeoffice.facts import TIMEOFFICE_FACTS from scheduling.timeoffice.service import TimeOfficeService @@ -80,87 +80,90 @@ def _weekly_wish_shift_to_frontend(wish: WeeklyWish) -> str: return shift_fact.expected_code -@weeklyWishes_router.put("/global-wishes-and-blocked") +@weeklyWishes_router.put("/global-wishes-and-blocked/{employee_id}") async def put_global_wishes_and_blocked( + employee_id: int, planning_unit: int, from_date: date, request: CreateWishesAndBlockedRequest, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], ) -> SuccessResponse: from_date_year, from_date_month = from_date.year, from_date.month planning_month = PlanningMonth(year=from_date_year, month=from_date_month) + + if request.data.key != employee_id: + raise ValueError("employee_id path parameter does not match request.data.key.") + - weekly_wishes = _weekly_wishes_request_to_domain( - request=request.data, + weekly_wishes = _weekly_wishes_employee_request_to_domain( + employee=request.data, planning_unit=planning_unit, planning_month=planning_month, ) - logger.info( - "Received global wishes update: planning_unit=%s planning_month=%s weekly_wishes=%s", - planning_unit, - planning_month.label, - len(weekly_wishes), + timeoffice.replace_employee_weekly_wishes( + planning_unit_id=planning_unit, + planning_month=planning_month, + employee_id=employee_id, + weekly_wishes=weekly_wishes, ) - # TODO: In Datenbank schreiben - return SuccessResponse() -def _weekly_wishes_request_to_domain( +def _weekly_wishes_employee_request_to_domain( *, - request: WishesAndBlockedDatabaseRequest, + employee: WishesAndBlockedEmployeeRequest, planning_unit: int, planning_month: PlanningMonth, ) -> tuple[WeeklyWish, ...]: weekly_wishes: list[WeeklyWish] = [] - for employee in request.employees: - for weekday in employee.wish_days: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.PREFERRED_DAY, - ) + for weekday in employee.wish_days: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.PREFERRED_DAY, ) - - for weekday, shift_code in employee.wish_shifts: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.PREFERRED_SHIFT, - shift_id=_shift_id_from_frontend(shift_code), - ) + ) + + for weekday, shift_code in employee.wish_shifts: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.PREFERRED_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), ) - - for weekday in employee.blocked_days: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.FREE_DAY, - ) + ) + + for weekday in employee.blocked_days: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.FREE_DAY, ) - - for weekday, shift_code in employee.blocked_shifts: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.FREE_SHIFT, - shift_id=_shift_id_from_frontend(shift_code), - ) + ) + + for weekday, shift_code in employee.blocked_shifts: + weekly_wishes.append( + WeeklyWish( + employee_id=employee.key, + planning_unit_id=planning_unit, + planning_month=planning_month, + weekday=weekday, + type=WishType.FREE_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), ) + ) return tuple(weekly_wishes) @@ -176,3 +179,22 @@ def _shift_id_from_frontend(shift_code: str) -> int: return shift_id raise ValueError(f"Unknown shift code from wishes frontend: {shift_code!r}.") + + +@weeklyWishes_router.delete("/global-wishes-and-blocked/{employee_id}") +async def delete_global_wishes_and_blocked( + employee_id: int, + planning_unit: int, + month: int, + year: int, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> SuccessResponse: + planning_month = PlanningMonth(year=year, month=month) + + timeoffice.delete_employee_wishes( + planning_unit_id=planning_unit, + planning_month=planning_month, + employee_id=employee_id, + ) + + return SuccessResponse() diff --git a/src/scheduling/timeoffice/remapping/wishes.py b/src/scheduling/timeoffice/remapping/wishes.py index 51c2dde0..f6fdb011 100644 --- a/src/scheduling/timeoffice/remapping/wishes.py +++ b/src/scheduling/timeoffice/remapping/wishes.py @@ -1,6 +1,8 @@ +from calendar import monthrange from datetime import date as Date +from datetime import timedelta -from scheduling.domain import SchedulingBaseModel, Wish, WishType +from scheduling.domain import PlanningMonth, SchedulingBaseModel, WeeklyWish, Wish, WishType from scheduling.timeoffice.facts import TimeOfficeFacts @@ -110,3 +112,46 @@ def _absence_shift_id_for_wish_type(wish_type: WishType, *, facts: TimeOfficeFac raise ValueError(f"No TimeOffice absence shift configured for wish_type={wish_type}.") return absence_shift_id + + +def expand_weekly_wishes_to_monthly_wishes( + weekly_wishes: tuple[WeeklyWish, ...], +) -> tuple[Wish, ...]: + wishes: list[Wish] = [] + + for weekly_wish in weekly_wishes: + for wish_date in _dates_for_weekday( + planning_month=weekly_wish.planning_month, + weekday=weekly_wish.weekday, + ): + wishes.append( + Wish( + employee_id=weekly_wish.employee_id, + planning_unit_id=weekly_wish.planning_unit_id, + date=wish_date, + type=weekly_wish.type, + shift_id=weekly_wish.shift_id, + ) + ) + + return tuple(wishes) + + +def _dates_for_weekday( + *, + planning_month: PlanningMonth, + weekday: int, +) -> tuple[Date, ...]: + _, last_day = monthrange(planning_month.year, planning_month.month) + current = Date(planning_month.year, planning_month.month, 1) + + while current.isoweekday() != weekday: + current += timedelta(days=1) + + dates: list[Date] = [] + + while current.day <= last_day: + dates.append(current) + current += timedelta(days=7) + + return tuple(dates) diff --git a/src/scheduling/timeoffice/service.py b/src/scheduling/timeoffice/service.py index 886dde2a..957bc7f4 100644 --- a/src/scheduling/timeoffice/service.py +++ b/src/scheduling/timeoffice/service.py @@ -3,12 +3,13 @@ from sqlalchemy import Engine from scheduling.api.solve.schemas import SolveOptions -from scheduling.domain import PlanningMonth, SchedulingDataset, Wish +from scheduling.domain import PlanningMonth, SchedulingDataset, WeeklyWish, Wish from scheduling.solver.models import Solution from scheduling.timeoffice.facts import TimeOfficeFacts 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.remapping.wishes import expand_weekly_wishes_to_monthly_wishes from scheduling.timeoffice.writing.solution import LegacySolutionExportPaths, TimeOfficeSolutionWriter from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter from scheduling.validation import validate_scheduling_dataset @@ -174,3 +175,29 @@ def delete_employee_wishes( planning_month=planning_month, employee_id=employee_id, ) + + def replace_employee_weekly_wishes( + self, + *, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + weekly_wishes: tuple[WeeklyWish, ...], + ) -> None: + wishes = expand_weekly_wishes_to_monthly_wishes(weekly_wishes) + + with self._engine.begin() as connection: + self._wish_writer.delete_employee_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) + + self._wish_writer.insert_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + wishes=wishes, + facts=self._facts, + ) From 578eea183e49a41fd7c809863fe7a82b6a29f188 Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Mon, 6 Jul 2026 19:25:26 +0200 Subject: [PATCH 11/23] Wechsel von Monat, Jahr zu From Date --- src/scheduling/api/web/weeklyWishes_router.py | 9 ++++----- src/scheduling/api/web/wishes_router.py | 12 ++++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/scheduling/api/web/weeklyWishes_router.py b/src/scheduling/api/web/weeklyWishes_router.py index 7bb4e938..ee7cd82d 100644 --- a/src/scheduling/api/web/weeklyWishes_router.py +++ b/src/scheduling/api/web/weeklyWishes_router.py @@ -90,11 +90,10 @@ async def put_global_wishes_and_blocked( ) -> SuccessResponse: from_date_year, from_date_month = from_date.year, from_date.month planning_month = PlanningMonth(year=from_date_year, month=from_date_month) - + if request.data.key != employee_id: raise ValueError("employee_id path parameter does not match request.data.key.") - weekly_wishes = _weekly_wishes_employee_request_to_domain( employee=request.data, planning_unit=planning_unit, @@ -185,11 +184,11 @@ def _shift_id_from_frontend(shift_code: str) -> int: async def delete_global_wishes_and_blocked( employee_id: int, planning_unit: int, - month: int, - year: int, + from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], ) -> SuccessResponse: - planning_month = PlanningMonth(year=year, month=month) + from_date_year, from_date_month = from_date.year, from_date.month + planning_month = PlanningMonth(year=from_date_year, month=from_date_month) timeoffice.delete_employee_wishes( planning_unit_id=planning_unit, diff --git a/src/scheduling/api/web/wishes_router.py b/src/scheduling/api/web/wishes_router.py index b4b50066..a0932820 100644 --- a/src/scheduling/api/web/wishes_router.py +++ b/src/scheduling/api/web/wishes_router.py @@ -89,15 +89,15 @@ def _split_display_name(display_name: str) -> tuple[str, str]: async def replace_wishes_and_blocked( employee_id: int, planning_unit: int, - month: int, - year: int, + from_date: date, request: CreateWishesAndBlockedRequest, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], ) -> SuccessResponse: if request.data.key != employee_id: raise ValueError("employee_id path parameter does not match request.data.key.") - planning_month = PlanningMonth(year=year, month=month) + from_date_year, from_date_month = from_date.year, from_date.month + planning_month = PlanningMonth(year=from_date_year, month=from_date_month) wishes = _wishes_employee_request_to_domain( employee=request.data, @@ -180,11 +180,11 @@ def _shift_id_from_frontend(shift_code: str) -> int: async def delete_wishes_and_blocked( employee_id: int, planning_unit: int, - month: int, - year: int, + from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], ) -> SuccessResponse: - planning_month = PlanningMonth(year=year, month=month) + from_date_year, from_date_month = from_date.year, from_date.month + planning_month = PlanningMonth(year=from_date_year, month=from_date_month) timeoffice.delete_employee_wishes( planning_unit_id=planning_unit, From 946b0a7127993fa8121cbeb4ba2179f7b31a12ad Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Wed, 8 Jul 2026 16:44:26 +0200 Subject: [PATCH 12/23] Entfernen von weekly Wishes da wir uns darauf geeinigt haben, dass wir die nicht brauchen --- src/scheduling/api/app.py | 3 - src/scheduling/api/web/availability_router.py | 88 -------- src/scheduling/api/web/schemas.py | 16 -- src/scheduling/api/web/weeklyWishes_router.py | 199 ------------------ src/scheduling/domain/__init__.py | 3 +- src/scheduling/domain/dataset.py | 3 +- src/scheduling/domain/wish.py | 13 -- src/scheduling/timeoffice/mapping/dataset.py | 4 +- src/scheduling/timeoffice/mapping/wishes.py | 103 +-------- src/scheduling/timeoffice/remapping/wishes.py | 47 +---- src/scheduling/timeoffice/service.py | 29 +-- 11 files changed, 8 insertions(+), 500 deletions(-) delete mode 100644 src/scheduling/api/web/availability_router.py delete mode 100644 src/scheduling/api/web/weeklyWishes_router.py diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index 189fca8b..cfada57a 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -10,7 +10,6 @@ from scheduling.api.solve.router import solve_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.weeklyWishes_router import weeklyWishes_router from scheduling.api.web.wishes_router import wishes_router from scheduling.logging import configure_logging from scheduling.settings import get_settings @@ -64,9 +63,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(solve_router) app.include_router(employee_router) # app.include_router(weights_router) -# app.include_router(availability_router) app.include_router(wishes_router) -app.include_router(weeklyWishes_router) app.include_router(minimal_staff_router) diff --git a/src/scheduling/api/web/availability_router.py b/src/scheduling/api/web/availability_router.py deleted file mode 100644 index 44dd63d9..00000000 --- a/src/scheduling/api/web/availability_router.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging -from datetime import date -from typing import Annotated, Any - -from fastapi import APIRouter, Depends - -from scheduling.api.dependencies import get_timeoffice_service -from scheduling.domain import Availability, AvailabilityType, Employee, PlanningMonth -from scheduling.timeoffice.service import TimeOfficeService - -logger = logging.getLogger(__name__) - -availability_router = APIRouter() - - -@availability_router.get("/availability") -async def get_availability( - planning_unit: int, - year: int, - month: int, - timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], -) -> dict[str, list[dict[str, Any]]]: - month = PlanningMonth(year=year, month=month) - - dataset = timeoffice.fetch_dataset( - planning_unit_ids=(planning_unit,), - planning_month=month, - ) - - return {"employees": [_availability_to_frontend(employee, dataset.availability) for employee in dataset.employees]} - - -def _availability_to_frontend( - employee: Employee, - availabilities: tuple[Availability, ...], -) -> dict[str, Any]: - name, firstname = _split_display_name(employee.display_name) - - employee_availabilities = [ - availability for availability in availabilities if availability.employee_id == employee.employee_id - ] - - return { - "key": employee.employee_id, - "firstname": firstname, - "name": name, - "availability_days": [ - availability.date.day - for availability in employee_availabilities - if availability.availability_type == AvailabilityType.AVAILABLE_ONLY - ], - "unavailability_days": [ - availability.date.day - for availability in employee_availabilities - if availability.availability_type != AvailabilityType.AVAILABLE_ONLY - ], - } - - -def _split_display_name(display_name: str) -> tuple[str, str]: - name, _separator, firstname = display_name.partition(" ") - return name, firstname - - -@availability_router.put("/availability") -async def put_availability( - planning_unit: int, - from_date: date, - request: dict[str, Any], # Hier gerne wieder ein Request in Schema? -) -> dict[str, bool]: - month = PlanningMonth(year=from_date.year, month=from_date.month) - availability_json = request.get("data", {}) - - logger.info( - "Received availability update: planning_unit=%s planning_month=%s availability=%s", - planning_unit, - month.label, - availability_json, - ) - # TODO: Mappen der JSON auf das Domain - # TODO: Aufruf der Datenbank - - logger.info("Update availability in database") - - return {"success": True} - - -# TODO: Global Availabilities fehlen und werden zur Zeit nicht mit modelliert diff --git a/src/scheduling/api/web/schemas.py b/src/scheduling/api/web/schemas.py index 44b2db8f..53d7c0d7 100644 --- a/src/scheduling/api/web/schemas.py +++ b/src/scheduling/api/web/schemas.py @@ -3,22 +3,6 @@ from scheduling.domain.core import SchedulingBaseModel -class AvailabilityEmployeeRequest(SchedulingBaseModel): - key: int - firstname: str | None = None - name: str | None = None - availability_days: tuple[int, ...] = Field(default_factory=tuple) - unavailability_days: tuple[int, ...] = Field(default_factory=tuple) - - -class AvailabilityDatabaseRequest(SchedulingBaseModel): - employees: tuple[AvailabilityEmployeeRequest, ...] - - -class UpdateAvailabilityRequest(SchedulingBaseModel): - data: AvailabilityDatabaseRequest - - class WishesAndBlockedEmployeeRequest(SchedulingBaseModel): key: int firstname: str | None = None diff --git a/src/scheduling/api/web/weeklyWishes_router.py b/src/scheduling/api/web/weeklyWishes_router.py deleted file mode 100644 index ee7cd82d..00000000 --- a/src/scheduling/api/web/weeklyWishes_router.py +++ /dev/null @@ -1,199 +0,0 @@ -import logging -from datetime import date -from typing import Annotated, Any - -from fastapi import APIRouter, Depends - -from scheduling.api.dependencies import get_timeoffice_service -from scheduling.api.web.schemas import CreateWishesAndBlockedRequest, SuccessResponse, WishesAndBlockedEmployeeRequest -from scheduling.domain import Employee, PlanningMonth, WeeklyWish, WishType -from scheduling.timeoffice.facts import TIMEOFFICE_FACTS -from scheduling.timeoffice.service import TimeOfficeService - -logger = logging.getLogger(__name__) - -weeklyWishes_router = APIRouter() - - -@weeklyWishes_router.get("/global-wishes-and-blocked") -async def get_global_wishes_and_blocked( - planning_unit: int, - from_date: date, - timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], -) -> dict[str, list[dict[str, Any]]]: - from_date_year, from_date_month = from_date.year, from_date.month - planning_month = PlanningMonth(year=from_date_year, month=from_date_month) - - dataset = timeoffice.fetch_dataset( - planning_unit_ids=(planning_unit,), - planning_month=planning_month, - ) - - return { - "employees": [ - _weekly_wishes_to_frontend( - employee=employee, - weekly_wishes=dataset.weekly_wishes, - ) - for employee in dataset.employees - ] - } - - -def _weekly_wishes_to_frontend( - *, - employee: Employee, - weekly_wishes: tuple[WeeklyWish, ...], -) -> dict[str, Any]: - name, firstname = _split_display_name(employee.display_name) - - employee_wishes = [wish for wish in weekly_wishes if wish.employee_id == employee.employee_id] - - return { - "key": employee.employee_id, - "firstname": firstname, - "name": name, - "wish_days": [wish.weekday for wish in employee_wishes if wish.type == WishType.PREFERRED_DAY], - "wish_shifts": [ - [wish.weekday, _weekly_wish_shift_to_frontend(wish)] - for wish in employee_wishes - if wish.type == WishType.PREFERRED_SHIFT - ], - "blocked_days": [wish.weekday for wish in employee_wishes if wish.type == WishType.FREE_DAY], - "blocked_shifts": [ - [wish.weekday, _weekly_wish_shift_to_frontend(wish)] - for wish in employee_wishes - if wish.type == WishType.FREE_SHIFT - ], - } - - -def _weekly_wish_shift_to_frontend(wish: WeeklyWish) -> str: - if wish.shift_id is None: - raise ValueError(f"{wish.type} weekly wish requires shift_id.") - - shift_fact = TIMEOFFICE_FACTS.reference_shift_facts_by_id.get(wish.shift_id) - - if shift_fact is None: - raise ValueError(f"Unknown reference shift for weekly wish: employee_id={wish.employee_id}") - - return shift_fact.expected_code - - -@weeklyWishes_router.put("/global-wishes-and-blocked/{employee_id}") -async def put_global_wishes_and_blocked( - employee_id: int, - planning_unit: int, - from_date: date, - request: CreateWishesAndBlockedRequest, - timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], -) -> SuccessResponse: - from_date_year, from_date_month = from_date.year, from_date.month - planning_month = PlanningMonth(year=from_date_year, month=from_date_month) - - if request.data.key != employee_id: - raise ValueError("employee_id path parameter does not match request.data.key.") - - weekly_wishes = _weekly_wishes_employee_request_to_domain( - employee=request.data, - planning_unit=planning_unit, - planning_month=planning_month, - ) - - timeoffice.replace_employee_weekly_wishes( - planning_unit_id=planning_unit, - planning_month=planning_month, - employee_id=employee_id, - weekly_wishes=weekly_wishes, - ) - - return SuccessResponse() - - -def _weekly_wishes_employee_request_to_domain( - *, - employee: WishesAndBlockedEmployeeRequest, - planning_unit: int, - planning_month: PlanningMonth, -) -> tuple[WeeklyWish, ...]: - weekly_wishes: list[WeeklyWish] = [] - - for weekday in employee.wish_days: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.PREFERRED_DAY, - ) - ) - - for weekday, shift_code in employee.wish_shifts: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.PREFERRED_SHIFT, - shift_id=_shift_id_from_frontend(shift_code), - ) - ) - - for weekday in employee.blocked_days: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.FREE_DAY, - ) - ) - - for weekday, shift_code in employee.blocked_shifts: - weekly_wishes.append( - WeeklyWish( - employee_id=employee.key, - planning_unit_id=planning_unit, - planning_month=planning_month, - weekday=weekday, - type=WishType.FREE_SHIFT, - shift_id=_shift_id_from_frontend(shift_code), - ) - ) - - return tuple(weekly_wishes) - - -def _split_display_name(display_name: str) -> tuple[str, str]: - name, _separator, firstname = display_name.partition(" ") - return name, firstname - - -def _shift_id_from_frontend(shift_code: str) -> int: - 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 from wishes frontend: {shift_code!r}.") - - -@weeklyWishes_router.delete("/global-wishes-and-blocked/{employee_id}") -async def delete_global_wishes_and_blocked( - employee_id: int, - planning_unit: int, - from_date: date, - timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], -) -> SuccessResponse: - from_date_year, from_date_month = from_date.year, from_date.month - planning_month = PlanningMonth(year=from_date_year, month=from_date_month) - - timeoffice.delete_employee_wishes( - planning_unit_id=planning_unit, - planning_month=planning_month, - employee_id=employee_id, - ) - - return SuccessResponse() diff --git a/src/scheduling/domain/__init__.py b/src/scheduling/domain/__init__.py index 5d35d4a4..5c823198 100644 --- a/src/scheduling/domain/__init__.py +++ b/src/scheduling/domain/__init__.py @@ -10,7 +10,7 @@ from scheduling.domain.planning_unit import PlanningUnit, PlanningUnitId, PlanningUnitMembership, PlanningUnitType from scheduling.domain.shift import Shift, ShiftId, ShiftType, StaffingDemandRole from scheduling.domain.sunday_work_history import EmployeeSundayWorkHistory -from scheduling.domain.wish import WeeklyWish, Wish, WishType +from scheduling.domain.wish import Wish, WishType __all__ = [ "PositiveId", @@ -42,6 +42,5 @@ "EmployeeSundayWorkHistory", "Wish", "WishType", - "WeeklyWish", "MonthlyWorkAccount", ] diff --git a/src/scheduling/domain/dataset.py b/src/scheduling/domain/dataset.py index 8279dbfc..2c7c7ad4 100644 --- a/src/scheduling/domain/dataset.py +++ b/src/scheduling/domain/dataset.py @@ -9,7 +9,7 @@ from scheduling.domain.planning_unit import PlanningUnit, PlanningUnitMembership from scheduling.domain.shift import Shift from scheduling.domain.sunday_work_history import EmployeeSundayWorkHistory -from scheduling.domain.wish import WeeklyWish, Wish +from scheduling.domain.wish import Wish class SchedulingDataset(SchedulingBaseModel): @@ -31,7 +31,6 @@ class SchedulingDataset(SchedulingBaseModel): planning_unit_memberships: tuple[PlanningUnitMembership, ...] = () sunday_work_history: tuple[EmployeeSundayWorkHistory, ...] = () wishes: tuple[Wish, ...] = () - weekly_wishes: tuple[WeeklyWish, ...] assignments: tuple[Assignment, ...] = () availability: tuple[Availability, ...] = () diff --git a/src/scheduling/domain/wish.py b/src/scheduling/domain/wish.py index cca29436..dfde8d07 100644 --- a/src/scheduling/domain/wish.py +++ b/src/scheduling/domain/wish.py @@ -6,7 +6,6 @@ from scheduling.domain.core import SchedulingBaseModel from scheduling.domain.employee import EmployeeId -from scheduling.domain.planning_month import PlanningMonth from scheduling.domain.planning_unit import PlanningUnitId from scheduling.domain.shift import ShiftId @@ -35,15 +34,3 @@ def validate_wish(self) -> Self: raise ValueError(f"{self.type} wish must not define shift_id.") return self - - -class WeeklyWish(SchedulingBaseModel): - employee_id: EmployeeId - planning_unit_id: PlanningUnitId - planning_month: PlanningMonth - - weekday: int # weekday: 1=Monday, 7=Sunday - type: WishType - shift_id: ShiftId | None = None - - # TODO: Validator fehlt noch diff --git a/src/scheduling/timeoffice/mapping/dataset.py b/src/scheduling/timeoffice/mapping/dataset.py index bf22473a..e0337f51 100644 --- a/src/scheduling/timeoffice/mapping/dataset.py +++ b/src/scheduling/timeoffice/mapping/dataset.py @@ -20,7 +20,6 @@ def map_scheduling_dataset( planning_units = map_planning_units(sources.planning_unit_rows, facts=facts) plans = map_plans(sources.planning_unit_rows) shifts = map_shifts(sources.shift_rows, facts=facts) - mapped_wishes = map_wishes(rows=sources.wish_rows, shifts=shifts, facts=facts) return SchedulingDataset( planning_month=planning_month, @@ -48,7 +47,6 @@ def map_scheduling_dataset( facts=facts, ), sunday_work_history=map_sunday_work_history(sources.sunday_history_rows), - wishes=mapped_wishes.wishes, - weekly_wishes=mapped_wishes.weekly_wishes, + wishes=map_wishes(rows=sources.wish_rows, shifts=shifts, facts=facts), monthly_work_accounts=map_monthly_work_accounts(sources.monthly_work_account_rows), ) diff --git a/src/scheduling/timeoffice/mapping/wishes.py b/src/scheduling/timeoffice/mapping/wishes.py index ec868c6e..65c70e4c 100644 --- a/src/scheduling/timeoffice/mapping/wishes.py +++ b/src/scheduling/timeoffice/mapping/wishes.py @@ -1,28 +1,19 @@ -from calendar import monthrange from collections import defaultdict -from dataclasses import dataclass from datetime import date as Date -from datetime import timedelta -from scheduling.domain import PlanningMonth, Shift, WeeklyWish, Wish, WishType +from scheduling.domain import Shift, Wish, WishType from scheduling.domain.shift import ShiftType from scheduling.timeoffice.facts import TimeOfficeFacts from scheduling.timeoffice.mapping.shifts import reference_shift_id_for_source_shift from scheduling.timeoffice.reading.wishes import TimeOfficeWishRow -@dataclass(frozen=True, slots=True) -class MappedWishes: - wishes: tuple[Wish, ...] - weekly_wishes: tuple[WeeklyWish, ...] - - def map_wishes( rows: tuple[TimeOfficeWishRow, ...], *, shifts: tuple[Shift, ...], facts: TimeOfficeFacts, -) -> MappedWishes: +) -> tuple[Wish, ...]: known_shift_ids = {shift.shift_id for shift in shifts} wishes = tuple( @@ -37,7 +28,7 @@ def map_wishes( wishes = _deduplicate_wishes(wishes) wishes = _collapse_preferred_day_wishes(wishes, facts=facts) - return _split_weekly_repeated_wishes(wishes) + return _sort_wishes(wishes) def _map_wish( @@ -280,49 +271,6 @@ def _preferred_day_shift_ids(facts: TimeOfficeFacts) -> frozenset[int]: return shift_ids -def _split_weekly_repeated_wishes(wishes: tuple[Wish, ...]) -> MappedWishes: - wishes_by_key: dict[tuple[int, int, WishType, int | None, int], list[Wish]] = defaultdict(list) - - for wish in wishes: - key = ( - wish.employee_id, - wish.planning_unit_id, - wish.type, - wish.shift_id, - wish.date.isoweekday(), # 1=Mo, 7=So - ) - wishes_by_key[key].append(wish) - - normal_wishes: list[Wish] = [] - weekly_wishes: list[WeeklyWish] = [] - - for grouped_wishes in wishes_by_key.values(): - sorted_wishes = sorted(grouped_wishes, key=lambda wish: wish.date) - first_wish = sorted_wishes[0] - - if _is_weekly_repeated_in_month(sorted_wishes): - weekly_wishes.append( - WeeklyWish( - employee_id=first_wish.employee_id, - planning_unit_id=first_wish.planning_unit_id, - planning_month=PlanningMonth( - year=first_wish.date.year, - month=first_wish.date.month, - ), - weekday=first_wish.date.isoweekday(), - type=first_wish.type, - shift_id=first_wish.shift_id, - ) - ) - else: - normal_wishes.extend(sorted_wishes) - - return MappedWishes( - wishes=_sort_wishes(tuple(normal_wishes)), - weekly_wishes=_sort_weekly_wishes(tuple(weekly_wishes)), - ) - - def _sort_wishes(wishes: tuple[Wish, ...]) -> tuple[Wish, ...]: return tuple( sorted( @@ -336,48 +284,3 @@ def _sort_wishes(wishes: tuple[Wish, ...]) -> tuple[Wish, ...]: ), ) ) - - -def _sort_weekly_wishes(weekly_wishes: tuple[WeeklyWish, ...]) -> tuple[WeeklyWish, ...]: - return tuple( - sorted( - weekly_wishes, - key=lambda wish: ( - wish.employee_id, - wish.planning_unit_id, - wish.planning_month.year, - wish.planning_month.month, - wish.weekday, - wish.type.value, - wish.shift_id or -1, - ), - ) - ) - - -def _is_weekly_repeated_in_month(wishes: list[Wish]) -> bool: - if len(wishes) < 2: - return False - - dates = {wish.date for wish in wishes} - first_date = min(dates) - - expected_dates = set(_same_weekday_dates_in_month(first_date)) - - return dates == expected_dates - - -def _same_weekday_dates_in_month(first_date: Date) -> tuple[Date, ...]: - _, last_day = monthrange(first_date.year, first_date.month) - current = Date(first_date.year, first_date.month, 1) - - while current.isoweekday() != first_date.isoweekday(): - current += timedelta(days=1) - - dates: list[Date] = [] - - while current.month == first_date.month and current.day <= last_day: - dates.append(current) - current += timedelta(days=7) - - return tuple(dates) diff --git a/src/scheduling/timeoffice/remapping/wishes.py b/src/scheduling/timeoffice/remapping/wishes.py index f6fdb011..51c2dde0 100644 --- a/src/scheduling/timeoffice/remapping/wishes.py +++ b/src/scheduling/timeoffice/remapping/wishes.py @@ -1,8 +1,6 @@ -from calendar import monthrange from datetime import date as Date -from datetime import timedelta -from scheduling.domain import PlanningMonth, SchedulingBaseModel, WeeklyWish, Wish, WishType +from scheduling.domain import SchedulingBaseModel, Wish, WishType from scheduling.timeoffice.facts import TimeOfficeFacts @@ -112,46 +110,3 @@ def _absence_shift_id_for_wish_type(wish_type: WishType, *, facts: TimeOfficeFac raise ValueError(f"No TimeOffice absence shift configured for wish_type={wish_type}.") return absence_shift_id - - -def expand_weekly_wishes_to_monthly_wishes( - weekly_wishes: tuple[WeeklyWish, ...], -) -> tuple[Wish, ...]: - wishes: list[Wish] = [] - - for weekly_wish in weekly_wishes: - for wish_date in _dates_for_weekday( - planning_month=weekly_wish.planning_month, - weekday=weekly_wish.weekday, - ): - wishes.append( - Wish( - employee_id=weekly_wish.employee_id, - planning_unit_id=weekly_wish.planning_unit_id, - date=wish_date, - type=weekly_wish.type, - shift_id=weekly_wish.shift_id, - ) - ) - - return tuple(wishes) - - -def _dates_for_weekday( - *, - planning_month: PlanningMonth, - weekday: int, -) -> tuple[Date, ...]: - _, last_day = monthrange(planning_month.year, planning_month.month) - current = Date(planning_month.year, planning_month.month, 1) - - while current.isoweekday() != weekday: - current += timedelta(days=1) - - dates: list[Date] = [] - - while current.day <= last_day: - dates.append(current) - current += timedelta(days=7) - - return tuple(dates) diff --git a/src/scheduling/timeoffice/service.py b/src/scheduling/timeoffice/service.py index 957bc7f4..886dde2a 100644 --- a/src/scheduling/timeoffice/service.py +++ b/src/scheduling/timeoffice/service.py @@ -3,13 +3,12 @@ from sqlalchemy import Engine from scheduling.api.solve.schemas import SolveOptions -from scheduling.domain import PlanningMonth, SchedulingDataset, WeeklyWish, Wish +from scheduling.domain import PlanningMonth, SchedulingDataset, Wish from scheduling.solver.models import Solution from scheduling.timeoffice.facts import TimeOfficeFacts 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.remapping.wishes import expand_weekly_wishes_to_monthly_wishes from scheduling.timeoffice.writing.solution import LegacySolutionExportPaths, TimeOfficeSolutionWriter from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter from scheduling.validation import validate_scheduling_dataset @@ -175,29 +174,3 @@ def delete_employee_wishes( planning_month=planning_month, employee_id=employee_id, ) - - def replace_employee_weekly_wishes( - self, - *, - planning_unit_id: int, - planning_month: PlanningMonth, - employee_id: int, - weekly_wishes: tuple[WeeklyWish, ...], - ) -> None: - wishes = expand_weekly_wishes_to_monthly_wishes(weekly_wishes) - - with self._engine.begin() as connection: - self._wish_writer.delete_employee_wishes( - connection=connection, - planning_unit_id=planning_unit_id, - planning_month=planning_month, - employee_id=employee_id, - ) - - self._wish_writer.insert_wishes( - connection=connection, - planning_unit_id=planning_unit_id, - planning_month=planning_month, - wishes=wishes, - facts=self._facts, - ) From 4a4b5b0bda70ed84d92538ea9f0311bff9435a19 Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Thu, 9 Jul 2026 10:59:02 +0200 Subject: [PATCH 13/23] =?UTF-8?q?Auslesen=20von=20Availabilities=20und=20W?= =?UTF-8?q?=C3=BCnschen=20zusammen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scheduling/api/app.py | 2 +- ...ter.py => wishes_availabilities_router.py} | 95 ++++++++++++++++--- 2 files changed, 82 insertions(+), 15 deletions(-) rename src/scheduling/api/web/{wishes_router.py => wishes_availabilities_router.py} (69%) diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index cfada57a..5d820880 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -10,7 +10,7 @@ from scheduling.api.solve.router import solve_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.wishes_router import wishes_router +from scheduling.api.web.wishes_availabilities_router import wishes_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 diff --git a/src/scheduling/api/web/wishes_router.py b/src/scheduling/api/web/wishes_availabilities_router.py similarity index 69% rename from src/scheduling/api/web/wishes_router.py rename to src/scheduling/api/web/wishes_availabilities_router.py index a0932820..d185866d 100644 --- a/src/scheduling/api/web/wishes_router.py +++ b/src/scheduling/api/web/wishes_availabilities_router.py @@ -6,7 +6,7 @@ from scheduling.api.dependencies import get_timeoffice_service from scheduling.api.web.schemas import CreateWishesAndBlockedRequest, SuccessResponse, WishesAndBlockedEmployeeRequest -from scheduling.domain import Employee, PlanningMonth, Wish, WishType +from scheduling.domain import Availability, AvailabilityType, Employee, PlanningMonth, Wish, WishType from scheduling.timeoffice.facts import TIMEOFFICE_FACTS from scheduling.timeoffice.service import TimeOfficeService @@ -29,45 +29,111 @@ async def get_wishes_and_blocked( planning_month=month, ) + employee_wishes_blocked = [ + _wishes_and_availability_to_frontend( + employee=employee, + wishes=dataset.wishes, + availability=dataset.availability, + ) + for employee in dataset.employees + ] + return { "employees": [ - _wishes_to_frontend( - employee=employee, - wishes=dataset.wishes, - ) - for employee in dataset.employees + employee_wish_block + for employee_wish_block in employee_wishes_blocked + if _has_any_wishes_or_availability(employee_wish_block) ] } -def _wishes_to_frontend( +def _has_any_wishes_or_availability(employee_block: dict[str, Any]) -> bool: + return any( + employee_block[field] + for field in ( + "blocked_days", + "blocked_shifts", + "wish_days", + "wish_shifts", + "work_days", + "work_shifts", + ) + ) + + +def _wishes_and_availability_to_frontend( *, employee: Employee, wishes: tuple[Wish, ...], + availability: tuple[Availability, ...], ) -> dict[str, Any]: name, firstname = _split_display_name(employee.display_name) employee_wishes = [wish for wish in wishes if wish.employee_id == employee.employee_id] + employee_availability = [item for item in availability if item.employee_id == employee.employee_id] + return { "key": employee.employee_id, "firstname": firstname, "name": name, - "wish_days": [wish.date.day for wish in employee_wishes if wish.type == WishType.PREFERRED_DAY], + # Availability + "blocked_days": [ + item.date.day for item in employee_availability if item.availability_type != AvailabilityType.AVAILABLE_ONLY + ], + "blocked_shifts": _blocked_shifts_to_frontend(employee_availability), + "wish_days": [wish.date.day for wish in employee_wishes if wish.type == WishType.FREE_DAY], "wish_shifts": [ [wish.date.day, _wish_shift_to_frontend(wish)] for wish in employee_wishes - if wish.type == WishType.PREFERRED_SHIFT + if wish.type == WishType.FREE_SHIFT ], - "blocked_days": [wish.date.day for wish in employee_wishes if wish.type == WishType.FREE_DAY], - "blocked_shifts": [ + "work_days": [wish.date.day for wish in employee_wishes if wish.type == WishType.PREFERRED_DAY], + "work_shifts": [ [wish.date.day, _wish_shift_to_frontend(wish)] for wish in employee_wishes - if wish.type == WishType.FREE_SHIFT + if wish.type == WishType.PREFERRED_SHIFT ], } +def _blocked_shifts_to_frontend( + availability_items: list[Availability], +) -> list[list[int | str]]: + shift_ids_by_code = _reference_shift_ids_by_code() + shift_codes_by_id = {shift_id: shift_code for shift_code, shift_id in shift_ids_by_code.items()} + + all_shift_codes = set(shift_ids_by_code) + + blocked_shifts: list[list[int | str]] = [] + + for item in availability_items: + if item.availability_type != AvailabilityType.AVAILABLE_ONLY: + continue + + if item.shift_ids is None: + continue + + allowed_shift_codes = { + shift_codes_by_id[shift_id] for shift_id in item.shift_ids if shift_id in shift_codes_by_id + } + + blocked_shift_codes = all_shift_codes - allowed_shift_codes + + for shift_code in sorted(blocked_shift_codes): + blocked_shifts.append([item.date.day, shift_code]) + + return blocked_shifts + + +def _reference_shift_ids_by_code() -> dict[str, int]: + return { + shift_fact.expected_code: shift_id + for shift_id, shift_fact in TIMEOFFICE_FACTS.reference_shift_facts_by_id.items() + if shift_fact.expected_code in {"F", "S", "N"} + } + + def _wish_shift_to_frontend(wish: Wish) -> str: if wish.shift_id is None: raise ValueError(f"{wish.type} wish requires shift_id.") @@ -104,13 +170,14 @@ async def replace_wishes_and_blocked( planning_unit=planning_unit, planning_month=planning_month, ) - + print(wishes) + """ timeoffice.replace_wishes( planning_unit_id=planning_unit, planning_month=planning_month, employee_id=employee_id, wishes=wishes, - ) + )""" return SuccessResponse() From d849ced6818e0abf104cd542a7553f105709e23f Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Fri, 10 Jul 2026 10:46:56 +0200 Subject: [PATCH 14/23] Wishes and Availabilities in einen Router gepackt und get gefiltert --- src/scheduling/api/app.py | 4 ++-- .../api/web/wishes_availabilities_router.py | 21 +++++++++++++++---- src/scheduling/timeoffice/facts.py | 3 +++ src/scheduling/timeoffice/remapping/wishes.py | 2 +- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index 5d820880..67a8745b 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -10,7 +10,7 @@ from scheduling.api.solve.router import solve_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.wishes_availabilities_router import wishes_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 @@ -63,7 +63,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(solve_router) app.include_router(employee_router) # app.include_router(weights_router) -app.include_router(wishes_router) +app.include_router(wishes_and_availabilities_router) app.include_router(minimal_staff_router) diff --git a/src/scheduling/api/web/wishes_availabilities_router.py b/src/scheduling/api/web/wishes_availabilities_router.py index d185866d..0c5f5af8 100644 --- a/src/scheduling/api/web/wishes_availabilities_router.py +++ b/src/scheduling/api/web/wishes_availabilities_router.py @@ -1,3 +1,4 @@ +import json import logging from datetime import date from typing import Annotated, Any @@ -12,10 +13,10 @@ logger = logging.getLogger(__name__) -wishes_router = APIRouter() +wishes_and_availabilities_router = APIRouter() -@wishes_router.get("/wishes-and-blocked") +@wishes_and_availabilities_router.get("/wishes-and-blocked") async def get_wishes_and_blocked( planning_unit: int, from_date: date, @@ -37,6 +38,18 @@ async def get_wishes_and_blocked( ) for employee in dataset.employees ] + logger.warning( + "DEBUG wishes/availability response: %s", + json.dumps( + { + "employees": [ + employee_wish_block + for employee_wish_block in employee_wishes_blocked + if _has_any_wishes_or_availability(employee_wish_block) + ] + } + ), + ) return { "employees": [ @@ -151,7 +164,7 @@ def _split_display_name(display_name: str) -> tuple[str, str]: return name, firstname -@wishes_router.put("/wishes-and-blocked/{employee_id}") +@wishes_and_availabilities_router.put("/wishes-and-blocked/{employee_id}") async def replace_wishes_and_blocked( employee_id: int, planning_unit: int, @@ -243,7 +256,7 @@ def _shift_id_from_frontend(shift_code: str) -> int: raise ValueError(f"Unknown shift code from wishes frontend: {shift_code!r}.") -@wishes_router.delete("/wishes-and-blocked/{employee_id}") +@wishes_and_availabilities_router.delete("/wishes-and-blocked/{employee_id}") async def delete_wishes_and_blocked( employee_id: int, planning_unit: int, diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index 53216d1f..0c9e3911 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -145,6 +145,8 @@ class TimeOfficeFacts: 2866: NIGHT_N2_SHIFT_ID, # N5 # Day/intermediate variant normalized to canonical T75_ intermediate shift. 2994: INTERMEDIATE_T75_SHIFT_ID, # T8x + 1234: INTERMEDIATE_T75_SHIFT_ID, + 1356: INTERMEDIATE_T75_SHIFT_ID, # Short/day special variants normalized to canonical Z60 non-minimum work shift. 2957: MANAGEMENT_Z60_SHIFT_ID, # Z52 2687: MANAGEMENT_Z60_SHIFT_ID, # Z52 @@ -190,6 +192,7 @@ class TimeOfficeFacts: "A-81302-016": StaffLevel.TRAINEE, # A-Pflegefachkraft Kinderkrankenpflege "A-81302-018": StaffLevel.TRAINEE, # A-Pflegefachkraft Krankenpflege "A-81302-019": StaffLevel.TRAINEE, # A-Pflegefachkraft Altenpflege + # "-": StaffLevel.TRAINEE,#Später herausfinden was das für eine Profession ist } ) diff --git a/src/scheduling/timeoffice/remapping/wishes.py b/src/scheduling/timeoffice/remapping/wishes.py index 51c2dde0..289cd5e6 100644 --- a/src/scheduling/timeoffice/remapping/wishes.py +++ b/src/scheduling/timeoffice/remapping/wishes.py @@ -13,7 +13,7 @@ class TimeOfficeWishWriteRow(SchedulingBaseModel): absence_shift_id: int | None = None -PREFERRED_DAY_SHIFT_CODES = ("F2_", "S2_", "N2_") +PREFERRED_DAY_SHIFT_CODES = ("F", "S", "N") def map_wishes_to_timeoffice_rows( From 95feec06947c97286baa0d52c2d15f8771b0f42c Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Sat, 11 Jul 2026 15:30:18 +0200 Subject: [PATCH 15/23] =?UTF-8?q?Update=20des=20GetMinStaffing=20Routings?= =?UTF-8?q?=20+=20Lesen=20und=20mappen=20des=20demands=20aus=20der=20Daten?= =?UTF-8?q?bank.=20Wenn=20die=20Tabelle=20nicht=20existiert=20wird=20sie?= =?UTF-8?q?=20neu=20generiert,=20daf=C3=BCr=20fehlen=20zur=20Zeit=20aber?= =?UTF-8?q?=20die=20create=20rechte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/web/minimal_staff_router.py | 150 +++++++++--------- src/scheduling/timeoffice/facts.py | 99 +++++------- src/scheduling/timeoffice/mapping/dataset.py | 1 + src/scheduling/timeoffice/mapping/demand.py | 118 ++++++++------ .../timeoffice/reading/container.py | 10 ++ src/scheduling/timeoffice/reading/demand.py | 105 ++++++++++++ src/scheduling/timeoffice/writing/solution.py | 18 +-- 7 files changed, 313 insertions(+), 188 deletions(-) create mode 100644 src/scheduling/timeoffice/reading/demand.py diff --git a/src/scheduling/api/web/minimal_staff_router.py b/src/scheduling/api/web/minimal_staff_router.py index d1ece581..f362c900 100644 --- a/src/scheduling/api/web/minimal_staff_router.py +++ b/src/scheduling/api/web/minimal_staff_router.py @@ -1,12 +1,13 @@ import logging from datetime import date -from typing import Annotated, Any +from typing import Annotated from fastapi import APIRouter, Depends from scheduling.api.dependencies import get_timeoffice_service from scheduling.api.web.schemas import SuccessResponse -from scheduling.domain import PlanningMonth +from scheduling.domain import DemandRequirement, PlanningMonth, StaffLevel +from scheduling.timeoffice.facts import TIMEOFFICE_FACTS from scheduling.timeoffice.service import TimeOfficeService logger = logging.getLogger(__name__) @@ -14,86 +15,85 @@ minimal_staff_router = APIRouter() +DAYS_OF_WEEK = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") + +WEEKDAY_NAME_BY_FRONTEND_DAY = { + "Mo": "Montag", + "Di": "Dienstag", + "Mi": "Mittwoch", + "Do": "Donnerstag", + "Fr": "Freitag", + "Sa": "Samstag", + "So": "Sonntag", +} + +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") + @minimal_staff_router.get("/minimal-staff") -async def get_minimal_staff_func( - planning_unit: int, from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)] -) -> Any: - """Return minimal staff requirements for a planning unit and month.""" +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)) - return _generate_minimal_staff_requirements(dataset) - - -def _generate_minimal_staff_requirements(dataset: Any) -> dict[str, dict[str, dict[str, int]]]: - level_map = {"trainee": "Azubi", "professional": "Fachkraft", "assistant": "Hilfskraft"} - - shift_map = {"early": "F", "late": "S", "night": "N"} - - days_of_week = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] - - output = {de_level: {day: {"F": 0, "N": 0, "S": 0} for day in days_of_week} for de_level in level_map.values()} - - if isinstance(dataset, dict): - demand_data = dataset.get("demand_requirements", []) - else: - demand_data = getattr(dataset, "demand_requirements", []) - - if demand_data: - for req in demand_data: - if isinstance(req, dict): - staff_level = req.get("staff_level") - shift_type = req.get("shift_type") - day_of_week = req.get("day_of_week") - min_required = req.get("min_required", 0) - else: - staff_level = getattr(req, "staff_level", None) - shift_type = getattr(req, "shift_type", None) - day_of_week = getattr(req, "day_of_week", None) - min_required = getattr(req, "min_required", 0) - - lvl = level_map.get(str(staff_level)) - shift = shift_map.get(str(shift_type)) - day = str(day_of_week) - - if lvl and shift and day in days_of_week: - output[lvl][day][shift] = min_required - else: - # Default data - output = { - "Azubi": { - "Di": {"F": 1, "N": 0, "S": 1}, - "Do": {"F": 1, "N": 0, "S": 1}, - "Fr": {"F": 1, "N": 0, "S": 1}, - "Mi": {"F": 1, "N": 0, "S": 1}, - "Mo": {"F": 1, "N": 0, "S": 1}, - "Sa": {"F": 1, "N": 0, "S": 1}, - "So": {"F": 1, "N": 0, "S": 1}, - }, - "Fachkraft": { - "Di": {"F": 3, "N": 2, "S": 2}, - "Do": {"F": 3, "N": 2, "S": 2}, - "Fr": {"F": 3, "N": 2, "S": 2}, - "Mi": {"F": 4, "N": 2, "S": 2}, - "Mo": {"F": 3, "N": 2, "S": 2}, - "Sa": {"F": 2, "N": 1, "S": 2}, - "So": {"F": 2, "N": 1, "S": 2}, - }, - "Hilfskraft": { - "Di": {"F": 2, "N": 0, "S": 2}, - "Do": {"F": 2, "N": 0, "S": 2}, - "Fr": {"F": 2, "N": 0, "S": 2}, - "Mi": {"F": 2, "N": 0, "S": 2}, - "Mo": {"F": 2, "N": 0, "S": 2}, - "Sa": {"F": 2, "N": 1, "S": 2}, - "So": {"F": 2, "N": 1, "S": 2}, - }, - } + + 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 + + # TODO: minimal_staff put missing, unsure where in DB to write to @minimal_staff_router.put("/minimal-staff") async def put_minimal_staff( diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index 0c9e3911..0adcc198 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -37,11 +37,14 @@ # TDienste.Prim values for the reduced reference shifts exposed to the solver. # These are the only TimeOffice shift IDs that should become canonical Shift.shift_id # values in the reduced SchedulingDataset. -EARLY_F2_SHIFT_ID = 2939 -LATE_S2_SHIFT_ID = 2947 -NIGHT_N2_SHIFT_ID = 2953 -INTERMEDIATE_T75_SHIFT_ID = 2906 -MANAGEMENT_Z60_SHIFT_ID = 1406 +# There are 2 Prim for the Early Shift: 1113 and 3000 +EARLY_SHIFT_ID = 1113 +# There are 3 Prim for the Late Shift: 1605, 2011, and 3002 +LATE_SHIFT_ID = 1605 +# There are 3 Prim for the Night Shift: 1690, 2449, and 3001 +NIGHT_SHIFT_ID = 1690 +# Ist die Intermediate Shift T(1410) oder Z(1453)? +INTERMEDIATE_SHIFT_ID = 1453 @dataclass(frozen=True, slots=True) @@ -79,7 +82,6 @@ class TimeOfficeFacts: work_shift_type_id: int reference_shift_facts_by_id: Mapping[ShiftId, TimeOfficeReferenceShiftFact] - wish_absence_shift_id_by_type: Mapping[WishType, int] # Non-reference source shift IDs normalized to reduced reference shifts. # Missing source shift ID => fail loudly in mapping. @@ -105,31 +107,26 @@ class TimeOfficeFacts: REFERENCE_SHIFT_FACTS_BY_ID: Mapping[ShiftId, TimeOfficeReferenceShiftFact] = MappingProxyType( { - EARLY_F2_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="F2_", + EARLY_SHIFT_ID: TimeOfficeReferenceShiftFact( + expected_code="F", type=ShiftType.EARLY, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, ), - LATE_S2_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="S2_", + LATE_SHIFT_ID: TimeOfficeReferenceShiftFact( + expected_code="S", type=ShiftType.LATE, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, ), - NIGHT_N2_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="N2_", + NIGHT_SHIFT_ID: TimeOfficeReferenceShiftFact( + expected_code="N", type=ShiftType.NIGHT, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, ), - INTERMEDIATE_T75_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="T75_", + INTERMEDIATE_SHIFT_ID: TimeOfficeReferenceShiftFact( + expected_code="Z", type=ShiftType.INTERMEDIATE, staffing_role=StaffingDemandRole.OPTIONAL_COVERAGE, ), - MANAGEMENT_Z60_SHIFT_ID: TimeOfficeReferenceShiftFact( - expected_code="Z60", - type=ShiftType.MANAGEMENT, - staffing_role=StaffingDemandRole.NON_MINIMUM_WORK, - ), } ) @@ -138,20 +135,18 @@ class TimeOfficeFacts: SHIFT_ID_OVERRIDES: Mapping[ShiftId, ShiftId] = MappingProxyType( { # Night variants normalized to canonical N2_ night shift. - 1692: NIGHT_N2_SHIFT_ID, # N15, partial night - 3076: NIGHT_N2_SHIFT_ID, # N5 - 2889: NIGHT_N2_SHIFT_ID, # N5 - 1698: NIGHT_N2_SHIFT_ID, # N5 - 2866: NIGHT_N2_SHIFT_ID, # N5 + 2939: EARLY_SHIFT_ID, # F2_ + 2947: LATE_SHIFT_ID, # S2_ + 2953: NIGHT_SHIFT_ID, # N2_ + 2906: INTERMEDIATE_SHIFT_ID, # T72_ + 1692: NIGHT_SHIFT_ID, # N15, partial night + 3076: NIGHT_SHIFT_ID, # N5 + 2889: NIGHT_SHIFT_ID, # N5 + 1698: NIGHT_SHIFT_ID, # N5 + 2866: NIGHT_SHIFT_ID, # N5 # Day/intermediate variant normalized to canonical T75_ intermediate shift. - 2994: INTERMEDIATE_T75_SHIFT_ID, # T8x - 1234: INTERMEDIATE_T75_SHIFT_ID, - 1356: INTERMEDIATE_T75_SHIFT_ID, - # Short/day special variants normalized to canonical Z60 non-minimum work shift. - 2957: MANAGEMENT_Z60_SHIFT_ID, # Z52 - 2687: MANAGEMENT_Z60_SHIFT_ID, # Z52 - 1403: MANAGEMENT_Z60_SHIFT_ID, # Z52 - 3066: MANAGEMENT_Z60_SHIFT_ID, # Z52 + 2994: INTERMEDIATE_SHIFT_ID, # T8x + 3066: INTERMEDIATE_SHIFT_ID, # Z52 } ) @@ -192,7 +187,6 @@ class TimeOfficeFacts: "A-81302-016": StaffLevel.TRAINEE, # A-Pflegefachkraft Kinderkrankenpflege "A-81302-018": StaffLevel.TRAINEE, # A-Pflegefachkraft Krankenpflege "A-81302-019": StaffLevel.TRAINEE, # A-Pflegefachkraft Altenpflege - # "-": StaffLevel.TRAINEE,#Später herausfinden was das für eine Profession ist } ) @@ -206,23 +200,23 @@ class TimeOfficeFacts: # Weekday tuple order: Mo, Di, Mi, Do, Fr, Sa, So. StaffLevel.PROFESSIONAL: MappingProxyType( { - EARLY_F2_SHIFT_ID: (3, 3, 4, 3, 3, 2, 2), - LATE_S2_SHIFT_ID: (2, 2, 2, 2, 2, 2, 2), - NIGHT_N2_SHIFT_ID: (2, 2, 2, 2, 2, 1, 1), + EARLY_SHIFT_ID: (3, 3, 4, 3, 3, 2, 2), + LATE_SHIFT_ID: (2, 2, 2, 2, 2, 2, 2), + NIGHT_SHIFT_ID: (2, 2, 2, 2, 2, 1, 1), } ), StaffLevel.ASSISTANT: MappingProxyType( { - EARLY_F2_SHIFT_ID: (2, 2, 2, 2, 2, 2, 2), - LATE_S2_SHIFT_ID: (2, 2, 2, 2, 2, 2, 2), - NIGHT_N2_SHIFT_ID: (0, 0, 0, 0, 0, 1, 1), + EARLY_SHIFT_ID: (2, 2, 2, 2, 2, 2, 2), + LATE_SHIFT_ID: (2, 2, 2, 2, 2, 2, 2), + NIGHT_SHIFT_ID: (0, 0, 0, 0, 0, 1, 1), } ), StaffLevel.TRAINEE: MappingProxyType( { - EARLY_F2_SHIFT_ID: (1, 1, 1, 1, 1, 1, 1), - LATE_S2_SHIFT_ID: (1, 1, 1, 1, 1, 1, 1), - NIGHT_N2_SHIFT_ID: (0, 0, 0, 0, 0, 0, 0), + EARLY_SHIFT_ID: (1, 1, 1, 1, 1, 1, 1), + LATE_SHIFT_ID: (1, 1, 1, 1, 1, 1, 1), + NIGHT_SHIFT_ID: (0, 0, 0, 0, 0, 0, 0), } ), } @@ -273,21 +267,21 @@ class TimeOfficeFacts: ), availability_type_by_absence_code=MappingProxyType( { - "U": AvailabilityType.VACATION, - "ZU": AvailabilityType.VACATION, + "U": AvailabilityType.VACATION, # Urlaub + "ZU": AvailabilityType.VACATION, # Zustatzurlaub # Conservative hard blockers until TimeOffice/domain semantics are confirmed. - "SC": AvailabilityType.UNAVAILABLE, - "EZ": AvailabilityType.UNAVAILABLE, - "RE": AvailabilityType.UNAVAILABLE, - "FI": AvailabilityType.UNAVAILABLE, + "SC": AvailabilityType.UNAVAILABLE, # Schulung + "EZ": AvailabilityType.UNAVAILABLE, # Elternzeit + "RE": AvailabilityType.UNAVAILABLE, # Reha + "FI": AvailabilityType.UNAVAILABLE, # Freistellung + "AZV": AvailabilityType.UNAVAILABLE, # Arbeitszeitverkürzung - vermutlich unavailable } ), ignored_availability_absence_codes=frozenset( { # Existing roster free/reduction markers. # They must not block solve-from-scratch. - "FR", - "AZV", + "FR", # geplanter Freier Tag - Soll der als Urlaub oder unavailable gehändelt werden } ), wish_type_by_absence_code=MappingProxyType( @@ -295,11 +289,6 @@ class TimeOfficeFacts: "FR": WishType.FREE_DAY, } ), - wish_absence_shift_id_by_type=MappingProxyType( - { - WishType.FREE_DAY: 1089, - } - ), monthly_target_work_account_id=MONTHLY_TARGET_WORK_ACCOUNT_ID, monthly_actual_work_account_id=MONTHLY_ACTUAL_WORK_ACCOUNT_ID, ) diff --git a/src/scheduling/timeoffice/mapping/dataset.py b/src/scheduling/timeoffice/mapping/dataset.py index e0337f51..6676ab5c 100644 --- a/src/scheduling/timeoffice/mapping/dataset.py +++ b/src/scheduling/timeoffice/mapping/dataset.py @@ -44,6 +44,7 @@ def map_scheduling_dataset( demand_requirements=map_demand_requirements( planning_month=planning_month, planning_units=planning_units, + rows=sources.demand_rows, facts=facts, ), sunday_work_history=map_sunday_work_history(sources.sunday_history_rows), diff --git a/src/scheduling/timeoffice/mapping/demand.py b/src/scheduling/timeoffice/mapping/demand.py index a8c791c5..faafa5f0 100644 --- a/src/scheduling/timeoffice/mapping/demand.py +++ b/src/scheduling/timeoffice/mapping/demand.py @@ -1,34 +1,52 @@ from datetime import timedelta -from scheduling.domain import DemandRequirement, PlanningMonth, PlanningUnit, PlanningUnitType, StaffingDemandRole +from scheduling.domain import DemandRequirement, PlanningMonth, PlanningUnit, PlanningUnitType +from scheduling.domain.employee import StaffLevel from scheduling.domain.shift import ShiftId -from scheduling.timeoffice.facts import PlanningUnitDemandMatrix, TimeOfficeFacts +from scheduling.timeoffice.facts import TimeOfficeFacts +from scheduling.timeoffice.reading.demand import TimeOfficeDemandRow + +WEEKDAY_INDEX_BY_NAME = { + "Montag": 0, + "Dienstag": 1, + "Mittwoch": 2, + "Donnerstag": 3, + "Freitag": 4, + "Samstag": 5, + "Sonntag": 6, +} + +STAFF_LEVEL_BY_FRONTEND_NAME = { + "Fachkraft": StaffLevel.PROFESSIONAL, + "Hilfskraft": StaffLevel.ASSISTANT, + "Azubi": StaffLevel.TRAINEE, +} + +MINIMAL_STAFF_SHIFT_CODES = {"F", "S", "N", "Z"} def map_demand_requirements( *, planning_month: PlanningMonth, planning_units: tuple[PlanningUnit, ...], + rows: tuple[TimeOfficeDemandRow, ...], facts: TimeOfficeFacts, ) -> tuple[DemandRequirement, ...]: + selected_station_ids = {unit.planning_unit_id for unit in planning_units if unit.type == PlanningUnitType.STATION} + requirements: list[DemandRequirement] = [] - selected_station_ids = sorted( - unit.planning_unit_id for unit in planning_units if unit.type == PlanningUnitType.STATION - ) + for row in rows: + if row.planning_unit_id not in selected_station_ids: + continue - for planning_unit_id in selected_station_ids: - demand_matrix = facts.fallback_demand_by_planning_unit.get(planning_unit_id) - if demand_matrix is None: - raise ValueError( - f"Missing fallback demand matrix for selected station planning_unit_id={planning_unit_id}." - ) + if row.minimum_count <= 0: + continue requirements.extend( - _expand_planning_unit_demand( - planning_unit_id=planning_unit_id, + _expand_demand_row( + row=row, planning_month=planning_month, - demand_matrix=demand_matrix, facts=facts, ) ) @@ -36,59 +54,63 @@ def map_demand_requirements( return tuple(requirements) -def _expand_planning_unit_demand( +def _expand_demand_row( *, - planning_unit_id: int, + row: TimeOfficeDemandRow, planning_month: PlanningMonth, - demand_matrix: PlanningUnitDemandMatrix, facts: TimeOfficeFacts, ) -> tuple[DemandRequirement, ...]: + weekday_index = _weekday_index_from_name(row.weekday_name) + staff_level = _staff_level_from_name(row.staff_level) + shift_id = _require_minimal_staffing_shift(row.shift_id, facts=facts) + requirements: list[DemandRequirement] = [] current_date = planning_month.start while current_date <= planning_month.end: - weekday_index = current_date.isoweekday() - 1 - - for staff_level, demand_by_shift_id in demand_matrix.items(): - for shift_id, weekday_demand in demand_by_shift_id.items(): - _validate_weekday_demand(shift_id=shift_id, weekday_demand=weekday_demand) - - _require_minimum_staffing_reference_shift(shift_id=shift_id, facts=facts) - - required_count = weekday_demand[weekday_index] - if required_count <= 0: - continue - - requirements.append( - DemandRequirement( - planning_unit_id=planning_unit_id, - date=current_date, - shift_id=shift_id, - staff_level=staff_level, - required_count=required_count, - ) + if current_date.isoweekday() - 1 == weekday_index: + requirements.append( + DemandRequirement( + planning_unit_id=row.planning_unit_id, + date=current_date, + shift_id=shift_id, + staff_level=staff_level, + required_count=row.minimum_count, ) + ) current_date += timedelta(days=1) return tuple(requirements) -def _validate_weekday_demand(*, shift_id: ShiftId, weekday_demand: tuple[int, ...]) -> None: - if len(weekday_demand) != 7: - raise ValueError( - "Fallback demand weekday tuple must contain exactly seven values " - f"(Mo, Di, Mi, Do, Fr, Sa, So): shift_id={shift_id} values={weekday_demand}." - ) +def _weekday_index_from_name(weekday_name: str) -> int: + try: + return WEEKDAY_INDEX_BY_NAME[weekday_name] + except KeyError as exc: + raise ValueError(f"Unknown minimal staffing weekday_name={weekday_name!r}.") from exc + + +def _staff_level_from_name(staff_level: str) -> StaffLevel: + try: + return STAFF_LEVEL_BY_FRONTEND_NAME[staff_level] + except KeyError as exc: + raise ValueError(f"Unknown minimal staffing staff_level={staff_level!r}.") from exc -def _require_minimum_staffing_reference_shift(*, shift_id: ShiftId, facts: TimeOfficeFacts) -> None: +def _require_minimal_staffing_shift( + shift_id: ShiftId, + *, + facts: TimeOfficeFacts, +) -> ShiftId: shift_fact = facts.reference_shift_facts_by_id.get(shift_id) if shift_fact is None: - raise ValueError(f"Fallback demand references non-reference shift_id={shift_id}.") + raise ValueError(f"Minimal staffing references unknown reference shift_id={shift_id}.") - if shift_fact.staffing_role != StaffingDemandRole.REQUIRED_MINIMUM: + if shift_fact.expected_code not in MINIMAL_STAFF_SHIFT_CODES: raise ValueError( - "Fallback demand must reference REQUIRED_MINIMUM shifts only: " - f"shift_id={shift_id} staffing_role={shift_fact.staffing_role}." + "Minimal staffing references unsupported shift: " + f"shift_id={shift_id}, expected_code={shift_fact.expected_code!r}." ) + + return shift_id diff --git a/src/scheduling/timeoffice/reading/container.py b/src/scheduling/timeoffice/reading/container.py index cf923a5d..2883dcb8 100644 --- a/src/scheduling/timeoffice/reading/container.py +++ b/src/scheduling/timeoffice/reading/container.py @@ -4,6 +4,7 @@ from scheduling.domain import PlanningMonth from scheduling.timeoffice.facts import TimeOfficeFacts +from scheduling.timeoffice.reading.demand import TimeOfficeDemandReader, TimeOfficeDemandRow from scheduling.timeoffice.reading.options import TimeOfficeOptionsReader from scheduling.timeoffice.reading.personnel import ( TimeOfficeEmployeeRow, @@ -37,6 +38,7 @@ class TimeOfficeSources: shift_rows: tuple[TimeOfficeShiftRow, ...] roster_rows: tuple[TimeOfficeRosterRow, ...] wish_rows: tuple[TimeOfficeWishRow, ...] + demand_rows: tuple[TimeOfficeDemandRow, ...] sunday_history_rows: tuple[TimeOfficeSundayHistoryRow, ...] monthly_work_account_rows: tuple[TimeOfficeMonthlyWorkAccountRow, ...] @@ -51,6 +53,7 @@ class TimeOfficeReaders: wishes: TimeOfficeWishReader sunday_work_history: TimeOfficeSundayWorkHistoryReader monthly_work_accounts: TimeOfficeMonthlyWorkAccountReader + demand: TimeOfficeDemandReader @classmethod def create(cls, *, facts: TimeOfficeFacts) -> "TimeOfficeReaders": @@ -63,6 +66,7 @@ def create(cls, *, facts: TimeOfficeFacts) -> "TimeOfficeReaders": wishes=TimeOfficeWishReader(), sunday_work_history=TimeOfficeSundayWorkHistoryReader(), monthly_work_accounts=TimeOfficeMonthlyWorkAccountReader(facts=facts), + demand=TimeOfficeDemandReader(), ) def read_sources( @@ -130,6 +134,11 @@ def read_sources( planning_month=planning_month, ) + demand_rows = self.demand.read_minimal_staffing( + connection=connection, + planning_unit_ids=planning_unit_ids, + ) + return TimeOfficeSources( planning_unit_rows=planning_unit_rows, plan_personnel_rows=plan_personnel_rows, @@ -140,6 +149,7 @@ def read_sources( wish_rows=wish_rows, sunday_history_rows=sunday_history_rows, monthly_work_account_rows=monthly_work_account_rows, + demand_rows=demand_rows, ) diff --git a/src/scheduling/timeoffice/reading/demand.py b/src/scheduling/timeoffice/reading/demand.py new file mode 100644 index 00000000..21d22447 --- /dev/null +++ b/src/scheduling/timeoffice/reading/demand.py @@ -0,0 +1,105 @@ +from sqlalchemy import Connection, bindparam, text + +from scheduling.domain.core import SchedulingBaseModel + + +class TimeOfficeDemandRow(SchedulingBaseModel): + planning_unit_id: int + weekday_name: str + staff_level: str + shift_id: int + minimum_count: int + + +class TimeOfficeDemandReader: + def read_minimal_staffing( + self, + *, + connection: Connection, + planning_unit_ids: tuple[int, ...], + ) -> tuple[TimeOfficeDemandRow, ...]: + self._ensure_minimal_staffing_table_exists(connection=connection) + + if not planning_unit_ids: + return () + + query = text( + """ + SELECT + RefPlanungseinheiten AS planning_unit_id, + WeekdayName AS weekday_name, + StaffLevel AS staff_level, + RefDienste AS shift_id, + MinimumCount AS minimum_count + FROM dbo.StaffSchedulingMinimalStaffing + WHERE RefPlanungseinheiten IN :planning_unit_ids + """ + ).bindparams(bindparam("planning_unit_ids", expanding=True)) + + rows = connection.execute( + query, + {"planning_unit_ids": planning_unit_ids}, + ).mappings() + + return tuple( + TimeOfficeDemandRow( + planning_unit_id=int(row["planning_unit_id"]), + weekday_name=str(row["weekday_name"]), + staff_level=str(row["staff_level"]), + shift_id=int(row["shift_id"]), + minimum_count=int(row["minimum_count"]), + ) + for row in rows + ) + + def _ensure_minimal_staffing_table_exists(self, *, connection: Connection) -> None: + query = text( + """ + IF OBJECT_ID(N'dbo.StaffSchedulingMinimalStaffing', N'U') IS NULL + BEGIN + CREATE TABLE dbo.StaffSchedulingMinimalStaffing ( + Id BIGINT IDENTITY(1,1) NOT NULL, + + RefPlanungseinheiten INT NOT NULL, + WeekdayName NVARCHAR(16) NOT NULL, + StaffLevel NVARCHAR(32) NOT NULL, + RefDienste INT NOT NULL, + MinimumCount INT NOT NULL, + + CONSTRAINT PK_StaffSchedulingMinimalStaffing + PRIMARY KEY (Id), + + CONSTRAINT CK_StaffSchedulingMinimalStaffing_WeekdayName + CHECK (WeekdayName IN ( + N'Montag', + N'Dienstag', + N'Mittwoch', + N'Donnerstag', + N'Freitag', + N'Samstag', + N'Sonntag' + )), + + CONSTRAINT CK_StaffSchedulingMinimalStaffing_StaffLevel + CHECK (StaffLevel IN ( + N'Fachkraft', + N'Hilfskraft', + N'Azubi' + )), + + CONSTRAINT CK_StaffSchedulingMinimalStaffing_MinimumCount + CHECK (MinimumCount >= 0), + + CONSTRAINT UQ_StaffSchedulingMinimalStaffing_BusinessKey + UNIQUE ( + RefPlanungseinheiten, + WeekdayName, + StaffLevel, + RefDienste + ) + ); + END + """ + ) + + connection.execute(query) diff --git a/src/scheduling/timeoffice/writing/solution.py b/src/scheduling/timeoffice/writing/solution.py index c1231ce2..65d9d239 100644 --- a/src/scheduling/timeoffice/writing/solution.py +++ b/src/scheduling/timeoffice/writing/solution.py @@ -11,21 +11,19 @@ from scheduling.domain.shift import ShiftId from scheduling.solver.models import Solution, SolutionStatus from scheduling.timeoffice.facts import ( - EARLY_F2_SHIFT_ID, - INTERMEDIATE_T75_SHIFT_ID, - LATE_S2_SHIFT_ID, - MANAGEMENT_Z60_SHIFT_ID, - NIGHT_N2_SHIFT_ID, + EARLY_SHIFT_ID, + INTERMEDIATE_SHIFT_ID, + LATE_SHIFT_ID, + NIGHT_SHIFT_ID, ) logger = logging.getLogger(__name__) LEGACY_SHIFT_ID_BY_REFERENCE_SHIFT_ID: dict[ShiftId, int] = { - EARLY_F2_SHIFT_ID: 0, - INTERMEDIATE_T75_SHIFT_ID: 1, - LATE_S2_SHIFT_ID: 2, - NIGHT_N2_SHIFT_ID: 3, - MANAGEMENT_Z60_SHIFT_ID: 4, + EARLY_SHIFT_ID: 0, + INTERMEDIATE_SHIFT_ID: 1, + LATE_SHIFT_ID: 2, + NIGHT_SHIFT_ID: 3, } LEGACY_SHIFTS: tuple[dict[str, Any], ...] = ( From 77a79302ae30cee7fccdfcf35c0014f40005a465 Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Sun, 12 Jul 2026 18:31:58 +0200 Subject: [PATCH 16/23] Added weights routing, reading, writing and domain - Noch nicht getestet da Create rechte der Datenbank fehlen --- src/scheduling/api/app.py | 7 +- .../api/web/minimal_staff_router.py | 115 ++++++++++++--- src/scheduling/api/web/schemas.py | 21 +++ src/scheduling/api/web/weights_router.py | 119 +++++++++------ src/scheduling/domain/__init__.py | 2 + src/scheduling/domain/dataset.py | 2 + src/scheduling/domain/objective_weights.py | 30 ++++ src/scheduling/timeoffice/mapping/dataset.py | 5 + .../timeoffice/mapping/objective_weights.py | 46 ++++++ .../timeoffice/reading/container.py | 10 ++ .../timeoffice/reading/objective_weights.py | 91 ++++++++++++ src/scheduling/timeoffice/remapping/demand.py | 65 ++++++++ .../timeoffice/remapping/objective_weights.py | 35 +++++ src/scheduling/timeoffice/service.py | 34 ++++- src/scheduling/timeoffice/writing/demand.py | 139 ++++++++++++++++++ .../timeoffice/writing/objective_weights.py | 132 +++++++++++++++++ 16 files changed, 785 insertions(+), 68 deletions(-) create mode 100644 src/scheduling/domain/objective_weights.py create mode 100644 src/scheduling/timeoffice/mapping/objective_weights.py create mode 100644 src/scheduling/timeoffice/reading/objective_weights.py create mode 100644 src/scheduling/timeoffice/remapping/demand.py create mode 100644 src/scheduling/timeoffice/remapping/objective_weights.py create mode 100644 src/scheduling/timeoffice/writing/demand.py create mode 100644 src/scheduling/timeoffice/writing/objective_weights.py diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index 67a8745b..2f1c5bf4 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -10,6 +10,7 @@ from scheduling.api.solve.router import solve_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.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 @@ -19,6 +20,8 @@ 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.solution import TimeOfficeSolutionWriter from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter @@ -44,6 +47,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: wish_writer=TimeOfficeWishWriter( target_planning_status_id=facts.target_planning_status_id, ), + demand_writer=TimeOfficeDemandWriter(), + objective_weights_writer=TimeOfficeWeightsWriter(), ), solver_service=SolverService( settings=settings, @@ -62,7 +67,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app = FastAPI(title="Staff Scheduling API", lifespan=lifespan) app.include_router(solve_router) app.include_router(employee_router) -# app.include_router(weights_router) +app.include_router(weights_router) app.include_router(wishes_and_availabilities_router) app.include_router(minimal_staff_router) diff --git a/src/scheduling/api/web/minimal_staff_router.py b/src/scheduling/api/web/minimal_staff_router.py index f362c900..2d92aab2 100644 --- a/src/scheduling/api/web/minimal_staff_router.py +++ b/src/scheduling/api/web/minimal_staff_router.py @@ -1,11 +1,11 @@ import logging -from datetime import date +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 +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 @@ -17,16 +17,6 @@ DAYS_OF_WEEK = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") -WEEKDAY_NAME_BY_FRONTEND_DAY = { - "Mo": "Montag", - "Di": "Dienstag", - "Mi": "Mittwoch", - "Do": "Donnerstag", - "Fr": "Freitag", - "Sa": "Samstag", - "So": "Sonntag", -} - FRONTEND_STAFF_LEVEL_BY_DOMAIN = { StaffLevel.TRAINEE: "Azubi", StaffLevel.PROFESSIONAL: "Fachkraft", @@ -43,6 +33,16 @@ 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( @@ -94,23 +94,92 @@ def _shift_code_from_shift_id(shift_id: int) -> str: return shift_fact.expected_code -# TODO: minimal_staff put missing, unsure where in DB to write to @minimal_staff_router.put("/minimal-staff") async def put_minimal_staff( planning_unit: int, from_date: date, - request: dict[str, dict[str, int]], -) -> dict[str, bool]: - month = PlanningMonth(year=from_date.year, month=from_date.month) - request_json = request.get("data", {}) + request: UpdateMinimalStaffRequest, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> SuccessResponse: + planning_month = PlanningMonth(year=from_date.year, month=from_date.month) - logger.info( - "Received minimal staff update: planning_unit=%s planning_month=%s minimal_staff=%s", - planning_unit, - month.label, - request_json, + demand_requirements = _minimal_staff_request_to_domain( + planning_unit=planning_unit, + planning_month=planning_month, + minimal_staff=request.data, ) - logger.info("Update availability in database") + 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) diff --git a/src/scheduling/api/web/schemas.py b/src/scheduling/api/web/schemas.py index 53d7c0d7..2f92764d 100644 --- a/src/scheduling/api/web/schemas.py +++ b/src/scheduling/api/web/schemas.py @@ -21,5 +21,26 @@ class CreateWishesAndBlockedRequest(SchedulingBaseModel): data: WishesAndBlockedEmployeeRequest +class UpdateMinimalStaffRequest(SchedulingBaseModel): + data: dict[str, dict[str, dict[str, int]]] + + +class WeightsRequestData(SchedulingBaseModel): + after_night: int = Field(ge=0) + consecutive_days: int = Field(ge=0) + consecutive_nights: int = Field(ge=0) + fairness: int = Field(ge=0) + free_weekend: int = Field(ge=0) + hidden: int = Field(ge=0) + overtime: int = Field(ge=0) + rotate: int = Field(ge=0) + second_weekend: int = Field(ge=0) + wishes: int = Field(ge=0) + + +class UpdateWeightsRequest(SchedulingBaseModel): + data: WeightsRequestData + + class SuccessResponse(SchedulingBaseModel): success: bool = True diff --git a/src/scheduling/api/web/weights_router.py b/src/scheduling/api/web/weights_router.py index 1d4e7766..fc586ab0 100644 --- a/src/scheduling/api/web/weights_router.py +++ b/src/scheduling/api/web/weights_router.py @@ -1,11 +1,12 @@ import logging from datetime import date -from typing import Annotated, Any +from typing import Annotated from fastapi import APIRouter, Depends from scheduling.api.dependencies import get_timeoffice_service -from scheduling.domain import PlanningMonth # Hier muss später noch Wish stehen +from scheduling.api.web.schemas import SuccessResponse, UpdateWeightsRequest, WeightsRequestData +from scheduling.domain import PlanningMonth, SolverObjectiveWeights from scheduling.timeoffice.service import TimeOfficeService logger = logging.getLogger(__name__) @@ -13,57 +14,89 @@ weights_router = APIRouter() -DEFAULT_WEIGHTS: dict[ - str, Any -] = {} # TODO: Default weights sollten in der Datenbank stehen und später ausgelesen werden - - @weights_router.get("/weights") async def get_weights( planning_unit: int, from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], -) -> dict[str, Any]: - """Return weights for a planning unit and month. - - TODO: Ersetzen des Kommentars fürs fetchen mit der richtigen Funktion - TODO: Default weights in die Datenbank schreiben und die dann fetchen - """ - # Wäre schöner, wenn Monat und Jahr im frontend übergeben werden -> ggf. noch ändern - month = PlanningMonth(year=from_date.year, month=from_date.month) - # weights = timeoffice.fetch_dataset(planning_unit_ids=(planning_unit,), planning_month=month).weights - logger.info( - "Fetching weights: planning_unit=%s planning_month=%s", - planning_unit, - month.label, +) -> dict[str, int]: + planning_month = PlanningMonth(year=from_date.year, month=from_date.month) + + dataset = timeoffice.fetch_dataset( + planning_unit_ids=(planning_unit,), + planning_month=planning_month, + ) + + objective_weights = _objective_weights_for_planning_unit( + planning_unit_id=planning_unit, + objective_weights=dataset.objective_weights, ) - # if weights is None: - # TODO: weights = Methode fetch default weights oder so - # TODO: Umwandeln der weights in die entsprechende json - # return weights - return DEFAULT_WEIGHTS # Muss durch weights ersetzt werden und dann können Default weights gelöscht werden + return _objective_weights_to_frontend(objective_weights) + + +def _objective_weights_for_planning_unit( + *, + planning_unit_id: int, + objective_weights: tuple[SolverObjectiveWeights, ...], +) -> SolverObjectiveWeights: + for weights in objective_weights: + if weights.planning_unit_id == planning_unit_id: + return weights + + return SolverObjectiveWeights.default_for_planning_unit(planning_unit_id) + + +def _objective_weights_to_frontend(weights: SolverObjectiveWeights) -> dict[str, int]: + return { + "after_night": weights.recovery_after_night_shift, + "consecutive_days": weights.consecutive_working_days, + "consecutive_nights": weights.consecutive_night_shifts, + "fairness": weights.fairness, + "free_weekend": weights.free_weekend, + "hidden": weights.hidden_employee, + "overtime": weights.overtime_penalty, + "rotate": weights.shift_rotation, + "second_weekend": weights.second_weekend_penalty, + "wishes": weights.employee_wish, + } @weights_router.put("/weights") async def put_weights( planning_unit: int, - from_date: date, - request: dict[str, Any], # Vielleicht schöner dem Request ein Schema zu geben -) -> dict[str, bool]: - """Update weights for a planning unit and month. - - TODO: Überführen der Gewichte ins Domain + Schreiben der Gewichte in die Datenbank - """ - month = PlanningMonth(year=from_date.year, month=from_date.month) - weights_json = request.get("data", {}) - - logger.info( - "Received weights update: planning_unit=%s planning_month=%s weights=%s", - planning_unit, - month.label, - weights_json, + from_date: date, # Die weights sind nicht monatsspezifisch -> Mit Frontend API abklären ob das benötigt wird + request: UpdateWeightsRequest, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> SuccessResponse: + objective_weights = _weights_request_to_domain( + planning_unit_id=planning_unit, + data=request.data, + ) + + timeoffice.replace_objective_weights( + planning_unit_id=planning_unit, + objective_weights=objective_weights, + ) + + return SuccessResponse() + + +def _weights_request_to_domain( + *, + planning_unit_id: int, + data: WeightsRequestData, +) -> SolverObjectiveWeights: + return SolverObjectiveWeights( + planning_unit_id=planning_unit_id, + recovery_after_night_shift=data.after_night, + consecutive_working_days=data.consecutive_days, + consecutive_night_shifts=data.consecutive_nights, + fairness=data.fairness, + free_weekend=data.free_weekend, + hidden_employee=data.hidden, + overtime_penalty=data.overtime, + shift_rotation=data.rotate, + second_weekend_penalty=data.second_weekend, + employee_wish=data.wishes, ) - # TODO: Überführen der weights_json in das Domain - logger.info("Update Weights in Database") - return {"success": True} diff --git a/src/scheduling/domain/__init__.py b/src/scheduling/domain/__init__.py index 5c823198..05f9ba94 100644 --- a/src/scheduling/domain/__init__.py +++ b/src/scheduling/domain/__init__.py @@ -5,6 +5,7 @@ from scheduling.domain.demand import DemandRequirement from scheduling.domain.employee import Capability, Employee, EmployeeId, StaffLevel from scheduling.domain.monthly_work_account import MonthlyWorkAccount +from scheduling.domain.objective_weights import SolverObjectiveWeights from scheduling.domain.plan import Plan, PlanId from scheduling.domain.planning_month import PlanningMonth from scheduling.domain.planning_unit import PlanningUnit, PlanningUnitId, PlanningUnitMembership, PlanningUnitType @@ -43,4 +44,5 @@ "Wish", "WishType", "MonthlyWorkAccount", + "SolverObjectiveWeights", ] diff --git a/src/scheduling/domain/dataset.py b/src/scheduling/domain/dataset.py index 2c7c7ad4..2811f09a 100644 --- a/src/scheduling/domain/dataset.py +++ b/src/scheduling/domain/dataset.py @@ -4,6 +4,7 @@ from scheduling.domain.demand import DemandRequirement from scheduling.domain.employee import Employee from scheduling.domain.monthly_work_account import MonthlyWorkAccount +from scheduling.domain.objective_weights import SolverObjectiveWeights from scheduling.domain.plan import Plan from scheduling.domain.planning_month import PlanningMonth from scheduling.domain.planning_unit import PlanningUnit, PlanningUnitMembership @@ -36,3 +37,4 @@ class SchedulingDataset(SchedulingBaseModel): availability: tuple[Availability, ...] = () monthly_work_accounts: tuple[MonthlyWorkAccount, ...] = () + objective_weights: tuple[SolverObjectiveWeights, ...] = () diff --git a/src/scheduling/domain/objective_weights.py b/src/scheduling/domain/objective_weights.py new file mode 100644 index 00000000..cb01354f --- /dev/null +++ b/src/scheduling/domain/objective_weights.py @@ -0,0 +1,30 @@ +from typing import Self + +from pydantic import Field + +from scheduling.domain.core import SchedulingBaseModel +from scheduling.domain.planning_unit import PlanningUnitId + + +class SolverObjectiveWeights(SchedulingBaseModel): + """Weights for soft objectives used by the solver. + + These are planning-unit specific solver preferences. + """ + + planning_unit_id: PlanningUnitId + + recovery_after_night_shift: int = Field(default=3, ge=0) + consecutive_working_days: int = Field(default=1, ge=0) + consecutive_night_shifts: int = Field(default=2, ge=0) + fairness: int = Field(default=3, ge=0) + free_weekend: int = Field(default=3, ge=0) + hidden_employee: int = Field(default=100, ge=0) + overtime_penalty: int = Field(default=4, ge=0) + shift_rotation: int = Field(default=1, ge=0) + second_weekend_penalty: int = Field(default=1, ge=0) + employee_wish: int = Field(default=3, ge=0) + + @classmethod + def default_for_planning_unit(cls, planning_unit_id: PlanningUnitId) -> Self: + return cls(planning_unit_id=planning_unit_id) diff --git a/src/scheduling/timeoffice/mapping/dataset.py b/src/scheduling/timeoffice/mapping/dataset.py index 6676ab5c..d97ffbc9 100644 --- a/src/scheduling/timeoffice/mapping/dataset.py +++ b/src/scheduling/timeoffice/mapping/dataset.py @@ -1,6 +1,7 @@ from scheduling.domain import PlanningMonth, SchedulingDataset from scheduling.timeoffice.facts import TimeOfficeFacts from scheduling.timeoffice.mapping.demand import map_demand_requirements +from scheduling.timeoffice.mapping.objective_weights import map_objective_weights from scheduling.timeoffice.mapping.personnel import map_employees, map_planning_unit_memberships from scheduling.timeoffice.mapping.planning import map_planning_units, map_plans from scheduling.timeoffice.mapping.roster import map_assignments, map_availability @@ -50,4 +51,8 @@ def map_scheduling_dataset( sunday_work_history=map_sunday_work_history(sources.sunday_history_rows), wishes=map_wishes(rows=sources.wish_rows, shifts=shifts, facts=facts), monthly_work_accounts=map_monthly_work_accounts(sources.monthly_work_account_rows), + objective_weights=map_objective_weights( + planning_units=planning_units, + rows=sources.objective_weight_rows, + ), ) diff --git a/src/scheduling/timeoffice/mapping/objective_weights.py b/src/scheduling/timeoffice/mapping/objective_weights.py new file mode 100644 index 00000000..3a7a6924 --- /dev/null +++ b/src/scheduling/timeoffice/mapping/objective_weights.py @@ -0,0 +1,46 @@ +from scheduling.domain import PlanningUnit, PlanningUnitType, SolverObjectiveWeights +from scheduling.timeoffice.reading.objective_weights import TimeOfficeObjectiveWeightRow + +OBJECTIVE_FIELDS = set(SolverObjectiveWeights.model_fields) - {"planning_unit_id"} + + +def map_objective_weights( + *, + planning_units: tuple[PlanningUnit, ...], + rows: tuple[TimeOfficeObjectiveWeightRow, ...], +) -> tuple[SolverObjectiveWeights, ...]: + selected_station_ids = sorted( + unit.planning_unit_id for unit in planning_units if unit.type == PlanningUnitType.STATION + ) + + rows_by_planning_unit_id: dict[int, list[TimeOfficeObjectiveWeightRow]] = { + planning_unit_id: [] for planning_unit_id in selected_station_ids + } + + for row in rows: + if row.planning_unit_id in rows_by_planning_unit_id: + rows_by_planning_unit_id[row.planning_unit_id].append(row) + + return tuple( + _map_objective_weights_for_planning_unit( + planning_unit_id=planning_unit_id, + rows=tuple(rows_by_planning_unit_id[planning_unit_id]), + ) + for planning_unit_id in selected_station_ids + ) + + +def _map_objective_weights_for_planning_unit( + *, + planning_unit_id: int, + rows: tuple[TimeOfficeObjectiveWeightRow, ...], +) -> SolverObjectiveWeights: + values = SolverObjectiveWeights.default_for_planning_unit(planning_unit_id).model_dump() + + for row in rows: + if row.objective_name not in OBJECTIVE_FIELDS: + raise ValueError(f"Unknown objective weight field: {row.objective_name!r}.") + + values[row.objective_name] = row.weight + + return SolverObjectiveWeights(**values) diff --git a/src/scheduling/timeoffice/reading/container.py b/src/scheduling/timeoffice/reading/container.py index 2883dcb8..49e3e4de 100644 --- a/src/scheduling/timeoffice/reading/container.py +++ b/src/scheduling/timeoffice/reading/container.py @@ -5,6 +5,7 @@ from scheduling.domain import PlanningMonth from scheduling.timeoffice.facts import TimeOfficeFacts from scheduling.timeoffice.reading.demand import TimeOfficeDemandReader, TimeOfficeDemandRow +from scheduling.timeoffice.reading.objective_weights import TimeOfficeObjectiveWeightRow, TimeOfficeWeightsReader from scheduling.timeoffice.reading.options import TimeOfficeOptionsReader from scheduling.timeoffice.reading.personnel import ( TimeOfficeEmployeeRow, @@ -41,6 +42,7 @@ class TimeOfficeSources: demand_rows: tuple[TimeOfficeDemandRow, ...] sunday_history_rows: tuple[TimeOfficeSundayHistoryRow, ...] monthly_work_account_rows: tuple[TimeOfficeMonthlyWorkAccountRow, ...] + objective_weight_rows: tuple[TimeOfficeObjectiveWeightRow, ...] @dataclass(frozen=True, slots=True) @@ -54,6 +56,7 @@ class TimeOfficeReaders: sunday_work_history: TimeOfficeSundayWorkHistoryReader monthly_work_accounts: TimeOfficeMonthlyWorkAccountReader demand: TimeOfficeDemandReader + weights: TimeOfficeWeightsReader @classmethod def create(cls, *, facts: TimeOfficeFacts) -> "TimeOfficeReaders": @@ -67,6 +70,7 @@ def create(cls, *, facts: TimeOfficeFacts) -> "TimeOfficeReaders": sunday_work_history=TimeOfficeSundayWorkHistoryReader(), monthly_work_accounts=TimeOfficeMonthlyWorkAccountReader(facts=facts), demand=TimeOfficeDemandReader(), + weights=TimeOfficeWeightsReader(), ) def read_sources( @@ -139,6 +143,11 @@ def read_sources( planning_unit_ids=planning_unit_ids, ) + objective_weight_rows = self.weights.read_rows( + connection=connection, + planning_unit_ids=planning_unit_ids, + ) + return TimeOfficeSources( planning_unit_rows=planning_unit_rows, plan_personnel_rows=plan_personnel_rows, @@ -150,6 +159,7 @@ def read_sources( sunday_history_rows=sunday_history_rows, monthly_work_account_rows=monthly_work_account_rows, demand_rows=demand_rows, + objective_weight_rows=objective_weight_rows, ) diff --git a/src/scheduling/timeoffice/reading/objective_weights.py b/src/scheduling/timeoffice/reading/objective_weights.py new file mode 100644 index 00000000..a638e880 --- /dev/null +++ b/src/scheduling/timeoffice/reading/objective_weights.py @@ -0,0 +1,91 @@ +from sqlalchemy import Connection, bindparam, text + +from scheduling.domain.core import SchedulingBaseModel + + +class TimeOfficeObjectiveWeightRow(SchedulingBaseModel): + planning_unit_id: int + objective_name: str + weight: int + + +class TimeOfficeWeightsReader: + def read_rows( + self, + *, + connection: Connection, + planning_unit_ids: tuple[int, ...], + ) -> tuple[TimeOfficeObjectiveWeightRow, ...]: + self._ensure_objective_weights_table_exists(connection=connection) + + if not planning_unit_ids: + return () + + query = text( + """ + SELECT + RefPlanungseinheiten AS planning_unit_id, + ObjectiveName AS objective_name, + Weight AS weight + FROM dbo.StaffSchedulingObjectiveWeights + WHERE RefPlanungseinheiten IN :planning_unit_ids + """ + ).bindparams(bindparam("planning_unit_ids", expanding=True)) + + rows = connection.execute( + query, + {"planning_unit_ids": planning_unit_ids}, + ).mappings() + + return tuple( + TimeOfficeObjectiveWeightRow( + planning_unit_id=int(row["planning_unit_id"]), + objective_name=str(row["objective_name"]), + weight=int(row["weight"]), + ) + for row in rows + ) + + def _ensure_objective_weights_table_exists(self, *, connection: Connection) -> None: + query = text( + """ + IF OBJECT_ID(N'dbo.StaffSchedulingObjectiveWeights', N'U') IS NULL + BEGIN + CREATE TABLE dbo.StaffSchedulingObjectiveWeights ( + Id BIGINT IDENTITY(1,1) NOT NULL, + + RefPlanungseinheiten INT NOT NULL, + ObjectiveName NVARCHAR(80) NOT NULL, + Weight INT NOT NULL, + + CONSTRAINT PK_StaffSchedulingObjectiveWeights + PRIMARY KEY (Id), + + CONSTRAINT CK_StaffSchedulingObjectiveWeights_ObjectiveName + CHECK (ObjectiveName IN ( + N'recovery_after_night_shift', + N'consecutive_working_days', + N'consecutive_night_shifts', + N'fairness', + N'free_weekend', + N'hidden_employee', + N'overtime_penalty', + N'shift_rotation', + N'second_weekend_penalty', + N'employee_wish' + )), + + CONSTRAINT CK_StaffSchedulingObjectiveWeights_Weight + CHECK (Weight >= 0), + + CONSTRAINT UQ_StaffSchedulingObjectiveWeights_BusinessKey + UNIQUE ( + RefPlanungseinheiten, + ObjectiveName + ) + ); + END + """ + ) + + connection.execute(query) diff --git a/src/scheduling/timeoffice/remapping/demand.py b/src/scheduling/timeoffice/remapping/demand.py new file mode 100644 index 00000000..a7aa2e1e --- /dev/null +++ b/src/scheduling/timeoffice/remapping/demand.py @@ -0,0 +1,65 @@ +from scheduling.domain import DemandRequirement +from scheduling.domain.core import SchedulingBaseModel +from scheduling.domain.employee import StaffLevel + + +class TimeOfficeMinimalStaffingWriteRow(SchedulingBaseModel): + planning_unit_id: int + weekday_name: str + staff_level: str + shift_id: int + minimum_count: int + + +WEEKDAY_NAME_BY_ISO_WEEKDAY = { + 1: "Montag", + 2: "Dienstag", + 3: "Mittwoch", + 4: "Donnerstag", + 5: "Freitag", + 6: "Samstag", + 7: "Sonntag", +} + +STAFF_LEVEL_NAME_BY_DOMAIN = { + StaffLevel.PROFESSIONAL: "Fachkraft", + StaffLevel.ASSISTANT: "Hilfskraft", + StaffLevel.TRAINEE: "Azubi", +} + + +def map_demand_requirements_to_minimal_staffing_rows( + demand_requirements: tuple[DemandRequirement, ...], +) -> tuple[TimeOfficeMinimalStaffingWriteRow, ...]: + rows_by_key: dict[tuple[int, str, str, int], int] = {} + + for requirement in demand_requirements: + weekday_name = WEEKDAY_NAME_BY_ISO_WEEKDAY[requirement.date.isoweekday()] + staff_level = STAFF_LEVEL_NAME_BY_DOMAIN[requirement.staff_level] + + key = ( + requirement.planning_unit_id, + weekday_name, + staff_level, + requirement.shift_id, + ) + + existing_count = rows_by_key.get(key) + if existing_count is not None and existing_count != requirement.required_count: + raise ValueError( + "Conflicting minimal staffing values for same planning_unit/weekday/staff_level/shift: " + f"key={key}, existing={existing_count}, new={requirement.required_count}." + ) + + rows_by_key[key] = requirement.required_count + + return tuple( + TimeOfficeMinimalStaffingWriteRow( + planning_unit_id=planning_unit_id, + weekday_name=weekday_name, + staff_level=staff_level, + shift_id=shift_id, + minimum_count=minimum_count, + ) + for (planning_unit_id, weekday_name, staff_level, shift_id), minimum_count in sorted(rows_by_key.items()) + ) diff --git a/src/scheduling/timeoffice/remapping/objective_weights.py b/src/scheduling/timeoffice/remapping/objective_weights.py new file mode 100644 index 00000000..495ab45e --- /dev/null +++ b/src/scheduling/timeoffice/remapping/objective_weights.py @@ -0,0 +1,35 @@ +from scheduling.domain import SolverObjectiveWeights +from scheduling.domain.core import SchedulingBaseModel + + +class TimeOfficeObjectiveWeightWriteRow(SchedulingBaseModel): + planning_unit_id: int + objective_name: str + weight: int + + +OBJECTIVE_NAMES = ( + "recovery_after_night_shift", + "consecutive_working_days", + "consecutive_night_shifts", + "fairness", + "free_weekend", + "hidden_employee", + "overtime_penalty", + "shift_rotation", + "second_weekend_penalty", + "employee_wish", +) + + +def map_objective_weights_to_timeoffice_rows( + objective_weights: SolverObjectiveWeights, +) -> tuple[TimeOfficeObjectiveWeightWriteRow, ...]: + return tuple( + TimeOfficeObjectiveWeightWriteRow( + planning_unit_id=objective_weights.planning_unit_id, + objective_name=objective_name, + weight=getattr(objective_weights, objective_name), + ) + for objective_name in OBJECTIVE_NAMES + ) diff --git a/src/scheduling/timeoffice/service.py b/src/scheduling/timeoffice/service.py index 886dde2a..1d208878 100644 --- a/src/scheduling/timeoffice/service.py +++ b/src/scheduling/timeoffice/service.py @@ -3,12 +3,14 @@ from sqlalchemy import Engine from scheduling.api.solve.schemas import SolveOptions -from scheduling.domain import PlanningMonth, SchedulingDataset, Wish +from scheduling.domain import DemandRequirement, PlanningMonth, SchedulingDataset, SolverObjectiveWeights, Wish from scheduling.solver.models import Solution from scheduling.timeoffice.facts import TimeOfficeFacts 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.demand import TimeOfficeDemandWriter +from scheduling.timeoffice.writing.objective_weights import TimeOfficeWeightsWriter from scheduling.timeoffice.writing.solution import LegacySolutionExportPaths, TimeOfficeSolutionWriter from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter from scheduling.validation import validate_scheduling_dataset @@ -27,12 +29,16 @@ def __init__( readers: TimeOfficeReaders, solution_writer: TimeOfficeSolutionWriter, wish_writer: TimeOfficeWishWriter, + demand_writer: TimeOfficeDemandWriter, + objective_weights_writer: TimeOfficeWeightsWriter, ) -> None: self._facts = facts self._engine = engine self._readers = readers self._solution_writer = solution_writer self._wish_writer = wish_writer + self._demand_writer = demand_writer + self._objective_weights_writer = objective_weights_writer def get_solve_options(self) -> SolveOptions: logger.info("Fetching TimeOffice solve options") @@ -174,3 +180,29 @@ def delete_employee_wishes( planning_month=planning_month, employee_id=employee_id, ) + + def replace_minimal_staffing( + self, + *, + planning_unit_id: int, + demand_requirements: tuple[DemandRequirement, ...], + ) -> None: + with self._engine.begin() as connection: + self._demand_writer.replace_minimal_staffing( + connection=connection, + planning_unit_id=planning_unit_id, + demand_requirements=demand_requirements, + ) + + def replace_objective_weights( + self, + *, + planning_unit_id: int, + objective_weights: SolverObjectiveWeights, + ) -> None: + with self._engine.begin() as connection: + self._objective_weights_writer.replace_objective_weights( + connection=connection, + planning_unit_id=planning_unit_id, + objective_weights=objective_weights, + ) diff --git a/src/scheduling/timeoffice/writing/demand.py b/src/scheduling/timeoffice/writing/demand.py new file mode 100644 index 00000000..92f285d6 --- /dev/null +++ b/src/scheduling/timeoffice/writing/demand.py @@ -0,0 +1,139 @@ +from sqlalchemy import Connection, text + +from scheduling.domain import DemandRequirement +from scheduling.timeoffice.remapping.demand import ( + TimeOfficeMinimalStaffingWriteRow, + map_demand_requirements_to_minimal_staffing_rows, +) + + +class TimeOfficeDemandWriter: + def replace_minimal_staffing( + self, + *, + connection: Connection, + planning_unit_id: int, + demand_requirements: tuple[DemandRequirement, ...], + ) -> None: + self._ensure_minimal_staffing_table_exists(connection=connection) + + rows = map_demand_requirements_to_minimal_staffing_rows(demand_requirements) + + self._delete_rows_for_planning_unit( + connection=connection, + planning_unit_id=planning_unit_id, + ) + + self._insert_rows( + connection=connection, + rows=rows, + ) + + def _delete_rows_for_planning_unit( + self, + *, + connection: Connection, + planning_unit_id: int, + ) -> None: + query = text( + """ + DELETE FROM dbo.StaffSchedulingMinimalStaffing + WHERE RefPlanungseinheiten = :planning_unit_id + """ + ) + + connection.execute(query, {"planning_unit_id": planning_unit_id}) + + def _insert_rows( + self, + *, + connection: Connection, + rows: tuple[TimeOfficeMinimalStaffingWriteRow, ...], + ) -> None: + if not rows: + return + + query = text( + """ + INSERT INTO dbo.StaffSchedulingMinimalStaffing ( + RefPlanungseinheiten, + WeekdayName, + StaffLevel, + RefDienste, + MinimumCount + ) + VALUES ( + :planning_unit_id, + :weekday_name, + :staff_level, + :shift_id, + :minimum_count + ) + """ + ) + + connection.execute( + query, + [ + { + "planning_unit_id": row.planning_unit_id, + "weekday_name": row.weekday_name, + "staff_level": row.staff_level, + "shift_id": row.shift_id, + "minimum_count": row.minimum_count, + } + for row in rows + ], + ) + + def _ensure_minimal_staffing_table_exists(self, *, connection: Connection) -> None: + query = text( + """ + IF OBJECT_ID(N'dbo.StaffSchedulingMinimalStaffing', N'U') IS NULL + BEGIN + CREATE TABLE dbo.StaffSchedulingMinimalStaffing ( + Id BIGINT IDENTITY(1,1) NOT NULL, + + RefPlanungseinheiten INT NOT NULL, + WeekdayName NVARCHAR(16) NOT NULL, + StaffLevel NVARCHAR(32) NOT NULL, + RefDienste INT NOT NULL, + MinimumCount INT NOT NULL, + + CONSTRAINT PK_StaffSchedulingMinimalStaffing + PRIMARY KEY (Id), + + CONSTRAINT CK_StaffSchedulingMinimalStaffing_WeekdayName + CHECK (WeekdayName IN ( + N'Montag', + N'Dienstag', + N'Mittwoch', + N'Donnerstag', + N'Freitag', + N'Samstag', + N'Sonntag' + )), + + CONSTRAINT CK_StaffSchedulingMinimalStaffing_StaffLevel + CHECK (StaffLevel IN ( + N'Fachkraft', + N'Hilfskraft', + N'Azubi' + )), + + CONSTRAINT CK_StaffSchedulingMinimalStaffing_MinimumCount + CHECK (MinimumCount >= 0), + + CONSTRAINT UQ_StaffSchedulingMinimalStaffing_BusinessKey + UNIQUE ( + RefPlanungseinheiten, + WeekdayName, + StaffLevel, + RefDienste + ) + ); + END + """ + ) + + connection.execute(query) diff --git a/src/scheduling/timeoffice/writing/objective_weights.py b/src/scheduling/timeoffice/writing/objective_weights.py new file mode 100644 index 00000000..540553e7 --- /dev/null +++ b/src/scheduling/timeoffice/writing/objective_weights.py @@ -0,0 +1,132 @@ +from sqlalchemy import Connection, text + +from scheduling.domain import SolverObjectiveWeights +from scheduling.timeoffice.remapping.objective_weights import ( + TimeOfficeObjectiveWeightWriteRow, + map_objective_weights_to_timeoffice_rows, +) + + +class TimeOfficeWeightsWriter: + def replace_objective_weights( + self, + *, + connection: Connection, + planning_unit_id: int, + objective_weights: SolverObjectiveWeights, + ) -> None: + if objective_weights.planning_unit_id != planning_unit_id: + raise ValueError( + "Objective weights planning_unit_id does not match target planning_unit_id: " + f"objective_weights.planning_unit_id={objective_weights.planning_unit_id}, " + f"planning_unit_id={planning_unit_id}." + ) + + self._ensure_objective_weights_table_exists(connection=connection) + + rows = map_objective_weights_to_timeoffice_rows(objective_weights) + + self._delete_rows_for_planning_unit( + connection=connection, + planning_unit_id=planning_unit_id, + ) + + self._insert_rows( + connection=connection, + rows=rows, + ) + + def _delete_rows_for_planning_unit( + self, + *, + connection: Connection, + planning_unit_id: int, + ) -> None: + query = text( + """ + DELETE FROM dbo.StaffSchedulingObjectiveWeights + WHERE RefPlanungseinheiten = :planning_unit_id + """ + ) + + connection.execute(query, {"planning_unit_id": planning_unit_id}) + + def _insert_rows( + self, + *, + connection: Connection, + rows: tuple[TimeOfficeObjectiveWeightWriteRow, ...], + ) -> None: + if not rows: + return + + query = text( + """ + INSERT INTO dbo.StaffSchedulingObjectiveWeights ( + RefPlanungseinheiten, + ObjectiveName, + Weight + ) + VALUES ( + :planning_unit_id, + :objective_name, + :weight + ) + """ + ) + + connection.execute( + query, + [ + { + "planning_unit_id": row.planning_unit_id, + "objective_name": row.objective_name, + "weight": row.weight, + } + for row in rows + ], + ) + + def _ensure_objective_weights_table_exists(self, *, connection: Connection) -> None: + query = text( + """ + IF OBJECT_ID(N'dbo.StaffSchedulingObjectiveWeights', N'U') IS NULL + BEGIN + CREATE TABLE dbo.StaffSchedulingObjectiveWeights ( + Id BIGINT IDENTITY(1,1) NOT NULL, + + RefPlanungseinheiten INT NOT NULL, + ObjectiveName NVARCHAR(80) NOT NULL, + Weight INT NOT NULL, + + CONSTRAINT PK_StaffSchedulingObjectiveWeights + PRIMARY KEY (Id), + + CONSTRAINT CK_StaffSchedulingObjectiveWeights_ObjectiveName + CHECK (ObjectiveName IN ( + N'recovery_after_night_shift', + N'consecutive_working_days', + N'consecutive_night_shifts', + N'fairness', + N'free_weekend', + N'hidden_employee', + N'overtime_penalty', + N'shift_rotation', + N'second_weekend_penalty', + N'employee_wish' + )), + + CONSTRAINT CK_StaffSchedulingObjectiveWeights_Weight + CHECK (Weight >= 0), + + CONSTRAINT UQ_StaffSchedulingObjectiveWeights_BusinessKey + UNIQUE ( + RefPlanungseinheiten, + ObjectiveName + ) + ); + END + """ + ) + + connection.execute(query) From 13f8de6324ddb09e3e11f30299bcc59d3debf85c Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Tue, 14 Jul 2026 14:34:59 +0200 Subject: [PATCH 17/23] Added Availabilities remapping + writing. Noch nicht mit Datenbank getestet durch die Datenbankprobleme --- src/scheduling/api/app.py | 2 + src/scheduling/api/web/schemas.py | 9 +- .../api/web/wishes_availabilities_router.py | 67 +++- src/scheduling/timeoffice/facts.py | 7 + src/scheduling/timeoffice/remapping/roster.py | 70 ++++ src/scheduling/timeoffice/service.py | 33 +- src/scheduling/timeoffice/writing/roster.py | 322 ++++++++++++++++++ 7 files changed, 489 insertions(+), 21 deletions(-) create mode 100644 src/scheduling/timeoffice/remapping/roster.py create mode 100644 src/scheduling/timeoffice/writing/roster.py diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index 2f1c5bf4..f92b2a07 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -22,6 +22,7 @@ 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 @@ -49,6 +50,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: ), demand_writer=TimeOfficeDemandWriter(), objective_weights_writer=TimeOfficeWeightsWriter(), + availability_writer=TimeOfficeAvailabilityWriter(), ), solver_service=SolverService( settings=settings, diff --git a/src/scheduling/api/web/schemas.py b/src/scheduling/api/web/schemas.py index 2f92764d..6013bc19 100644 --- a/src/scheduling/api/web/schemas.py +++ b/src/scheduling/api/web/schemas.py @@ -7,11 +7,16 @@ class WishesAndBlockedEmployeeRequest(SchedulingBaseModel): key: int firstname: str | None = None name: str | None = None - wish_days: tuple[int, ...] = Field(default_factory=tuple) - wish_shifts: tuple[tuple[int, str], ...] = Field(default_factory=tuple) + blocked_days: tuple[int, ...] = Field(default_factory=tuple) blocked_shifts: tuple[tuple[int, str], ...] = Field(default_factory=tuple) + wish_days: tuple[int, ...] = Field(default_factory=tuple) + wish_shifts: tuple[tuple[int, str], ...] = Field(default_factory=tuple) + + work_days: tuple[int, ...] = Field(default_factory=tuple) + work_shifts: tuple[tuple[int, str], ...] = Field(default_factory=tuple) + class WishesAndBlockedDatabaseRequest(SchedulingBaseModel): employees: tuple[WishesAndBlockedEmployeeRequest, ...] diff --git a/src/scheduling/api/web/wishes_availabilities_router.py b/src/scheduling/api/web/wishes_availabilities_router.py index 0c5f5af8..535f992b 100644 --- a/src/scheduling/api/web/wishes_availabilities_router.py +++ b/src/scheduling/api/web/wishes_availabilities_router.py @@ -175,22 +175,26 @@ async def replace_wishes_and_blocked( if request.data.key != employee_id: raise ValueError("employee_id path parameter does not match request.data.key.") - from_date_year, from_date_month = from_date.year, from_date.month - planning_month = PlanningMonth(year=from_date_year, month=from_date_month) + planning_month = PlanningMonth(year=from_date.year, month=from_date.month) wishes = _wishes_employee_request_to_domain( employee=request.data, planning_unit=planning_unit, planning_month=planning_month, ) - print(wishes) - """ - timeoffice.replace_wishes( + + availabilities = _availability_employee_request_to_domain( + employee=request.data, + planning_month=planning_month, + ) + + timeoffice.replace_employee_wishes_and_availability( planning_unit_id=planning_unit, planning_month=planning_month, employee_id=employee_id, wishes=wishes, - )""" + availabilities=availabilities, + ) return SuccessResponse() @@ -209,7 +213,7 @@ def _wishes_employee_request_to_domain( employee_id=employee.key, planning_unit_id=planning_unit, date=date(planning_month.year, planning_month.month, day), - type=WishType.PREFERRED_DAY, + type=WishType.FREE_DAY, ) ) @@ -219,28 +223,28 @@ def _wishes_employee_request_to_domain( employee_id=employee.key, planning_unit_id=planning_unit, date=date(planning_month.year, planning_month.month, day), - type=WishType.PREFERRED_SHIFT, + type=WishType.FREE_SHIFT, shift_id=_shift_id_from_frontend(shift_code), ) ) - for day in employee.blocked_days: + for day in employee.work_days: wishes.append( Wish( employee_id=employee.key, planning_unit_id=planning_unit, date=date(planning_month.year, planning_month.month, day), - type=WishType.FREE_DAY, + type=WishType.PREFERRED_DAY, ) ) - for day, shift_code in employee.blocked_shifts: + for day, shift_code in employee.work_shifts: wishes.append( Wish( employee_id=employee.key, planning_unit_id=planning_unit, date=date(planning_month.year, planning_month.month, day), - type=WishType.FREE_SHIFT, + type=WishType.PREFERRED_SHIFT, shift_id=_shift_id_from_frontend(shift_code), ) ) @@ -248,6 +252,43 @@ def _wishes_employee_request_to_domain( return tuple(wishes) +def _availability_employee_request_to_domain( + *, + employee: WishesAndBlockedEmployeeRequest, + planning_month: PlanningMonth, +) -> tuple[Availability, ...]: + availabilities: list[Availability] = [] + + blocked_shift_days = {day for day, _shift_code in employee.blocked_shifts} + + # Wichtig: Wenn ein Tag auch in blocked_shifts vorkommt, ist blocked_days nur die UI-Markierung + # und darf nicht zusätzlich als ganztägiger Block geschrieben werden + for day in employee.blocked_days: + if day in blocked_shift_days: + continue + + availabilities.append( + Availability( + employee_id=employee.key, + date=date(planning_month.year, planning_month.month, day), + availability_type=AvailabilityType.UNAVAILABLE, + shift_ids=None, + ) + ) + + for day, shift_code in employee.blocked_shifts: + availabilities.append( + Availability( + employee_id=employee.key, + date=date(planning_month.year, planning_month.month, day), + availability_type=AvailabilityType.UNAVAILABLE, + shift_ids=(_shift_id_from_frontend(shift_code),), + ) + ) + + return tuple(availabilities) + + def _shift_id_from_frontend(shift_code: str) -> int: for shift_id, shift_fact in TIMEOFFICE_FACTS.reference_shift_facts_by_id.items(): if shift_fact.expected_code == shift_code: @@ -266,7 +307,7 @@ async def delete_wishes_and_blocked( from_date_year, from_date_month = from_date.year, from_date.month planning_month = PlanningMonth(year=from_date_year, month=from_date_month) - timeoffice.delete_employee_wishes( + timeoffice.delete_employee_wishes_and_availability( planning_unit_id=planning_unit, planning_month=planning_month, employee_id=employee_id, diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index 0adcc198..0ab510b2 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -101,6 +101,8 @@ class TimeOfficeFacts: wish_type_by_absence_code: Mapping[str, WishType] + availability_absence_shift_id_by_type: Mapping[AvailabilityType, int] + monthly_target_work_account_id: int monthly_actual_work_account_id: int @@ -277,6 +279,11 @@ class TimeOfficeFacts: "AZV": AvailabilityType.UNAVAILABLE, # Arbeitszeitverkürzung - vermutlich unavailable } ), + availability_absence_shift_id_by_type=MappingProxyType( + { + AvailabilityType.UNAVAILABLE: 2738, # TODO: Mapped momentan auf SC. Durch richtigen Code ersetzen + } + ), ignored_availability_absence_codes=frozenset( { # Existing roster free/reduction markers. diff --git a/src/scheduling/timeoffice/remapping/roster.py b/src/scheduling/timeoffice/remapping/roster.py new file mode 100644 index 00000000..a5194e52 --- /dev/null +++ b/src/scheduling/timeoffice/remapping/roster.py @@ -0,0 +1,70 @@ +from datetime import date as Date + +from scheduling.domain import Availability, AvailabilityType +from scheduling.domain.core import SchedulingBaseModel +from scheduling.timeoffice.facts import TimeOfficeFacts + + +class TimeOfficeAvailabilityWriteRow(SchedulingBaseModel): + employee_id: int + plan_id: int + planning_unit_id: int + availability_date: Date + work_shift_id: int | None = None + absence_shift_id: int + + +def map_availabilities_to_timeoffice_rows( + *, + availabilities: tuple[Availability, ...], + plan_id: int, + planning_unit_id: int, + facts: TimeOfficeFacts, +) -> tuple[TimeOfficeAvailabilityWriteRow, ...]: + rows: list[TimeOfficeAvailabilityWriteRow] = [] + + for availability in availabilities: + absence_shift_id = _absence_shift_id_for_availability_type( + availability.availability_type, + facts=facts, + ) + + if availability.shift_ids is None: + rows.append( + TimeOfficeAvailabilityWriteRow( + employee_id=availability.employee_id, + plan_id=plan_id, + planning_unit_id=planning_unit_id, + availability_date=availability.date, + work_shift_id=None, + absence_shift_id=absence_shift_id, + ) + ) + continue + + for shift_id in availability.shift_ids: + rows.append( + TimeOfficeAvailabilityWriteRow( + employee_id=availability.employee_id, + plan_id=plan_id, + planning_unit_id=planning_unit_id, + availability_date=availability.date, + work_shift_id=shift_id, + absence_shift_id=absence_shift_id, + ) + ) + + return tuple(rows) + + +def _absence_shift_id_for_availability_type( + availability_type: AvailabilityType, + *, + facts: TimeOfficeFacts, +) -> int: + absence_shift_id = facts.availability_absence_shift_id_by_type.get(availability_type) + + if absence_shift_id is None: + raise ValueError(f"No TimeOffice absence shift configured for availability_type={availability_type}.") + + return absence_shift_id diff --git a/src/scheduling/timeoffice/service.py b/src/scheduling/timeoffice/service.py index 1d208878..2b2c04a8 100644 --- a/src/scheduling/timeoffice/service.py +++ b/src/scheduling/timeoffice/service.py @@ -3,7 +3,14 @@ from sqlalchemy import Engine from scheduling.api.solve.schemas import SolveOptions -from scheduling.domain import DemandRequirement, PlanningMonth, SchedulingDataset, SolverObjectiveWeights, Wish +from scheduling.domain import ( + Availability, + DemandRequirement, + PlanningMonth, + SchedulingDataset, + SolverObjectiveWeights, + Wish, +) from scheduling.solver.models import Solution from scheduling.timeoffice.facts import TimeOfficeFacts from scheduling.timeoffice.mapping import map_scheduling_dataset @@ -11,6 +18,7 @@ from scheduling.timeoffice.reading.container import TimeOfficeReaders 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 LegacySolutionExportPaths, TimeOfficeSolutionWriter from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter from scheduling.validation import validate_scheduling_dataset @@ -31,6 +39,7 @@ def __init__( wish_writer: TimeOfficeWishWriter, demand_writer: TimeOfficeDemandWriter, objective_weights_writer: TimeOfficeWeightsWriter, + availability_writer: TimeOfficeAvailabilityWriter, ) -> None: self._facts = facts self._engine = engine @@ -39,6 +48,7 @@ def __init__( self._wish_writer = wish_writer self._demand_writer = demand_writer self._objective_weights_writer = objective_weights_writer + self._availability_writer = availability_writer def get_solve_options(self) -> SolveOptions: logger.info("Fetching TimeOffice solve options") @@ -142,31 +152,35 @@ def _normalize_planning_unit_ids( return normalized - def replace_wishes( + def replace_employee_wishes_and_availability( self, *, planning_unit_id: int, planning_month: PlanningMonth, employee_id: int, wishes: tuple[Wish, ...], + availabilities: tuple[Availability, ...], ) -> None: with self._engine.begin() as connection: - self._wish_writer.delete_employee_wishes( + self._wish_writer.replace_employee_wishes( connection=connection, planning_unit_id=planning_unit_id, planning_month=planning_month, employee_id=employee_id, + wishes=wishes, + facts=self._facts, ) - self._wish_writer.insert_wishes( + self._availability_writer.replace_employee_availability( connection=connection, planning_unit_id=planning_unit_id, planning_month=planning_month, - wishes=wishes, + employee_id=employee_id, + availabilities=availabilities, facts=self._facts, ) - def delete_employee_wishes( + def delete_employee_wishes_and_availability( self, *, planning_unit_id: int, @@ -181,6 +195,13 @@ def delete_employee_wishes( employee_id=employee_id, ) + self._availability_writer.delete_employee_availability( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) + def replace_minimal_staffing( self, *, diff --git a/src/scheduling/timeoffice/writing/roster.py b/src/scheduling/timeoffice/writing/roster.py new file mode 100644 index 00000000..becd4557 --- /dev/null +++ b/src/scheduling/timeoffice/writing/roster.py @@ -0,0 +1,322 @@ +from datetime import date as Date + +from sqlalchemy import Connection, text + +from scheduling.domain import Availability, PlanningMonth +from scheduling.timeoffice.facts import TimeOfficeFacts +from scheduling.timeoffice.remapping.roster import ( + TimeOfficeAvailabilityWriteRow, + map_availabilities_to_timeoffice_rows, +) + +WEB_AVAILABILITY_INFO = "StaffSchedulingWebAvailability" + + +class TimeOfficeAvailabilityWriter: + def replace_employee_availability( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + availabilities: tuple[Availability, ...], + facts: TimeOfficeFacts, + ) -> None: + plan_id = self._find_target_plan_id( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + ) + + self._delete_web_availability_rows( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) + + rows = map_availabilities_to_timeoffice_rows( + availabilities=availabilities, + plan_id=plan_id, + planning_unit_id=planning_unit_id, + facts=facts, + ) + + self._insert_rows( + connection=connection, + rows=rows, + facts=facts, + ) + + def _delete_web_availability_rows( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + ) -> None: + query = text( + """ + DELETE FROM TPlanPersonalKommtGeht + WHERE RefPersonal = :employee_id + AND RefPlanungseinheiten = :planning_unit_id + AND CONVERT(date, Datum) BETWEEN :start AND :end + AND Info = :info + """ + ) + + connection.execute( + query, + { + "employee_id": employee_id, + "planning_unit_id": planning_unit_id, + "start": planning_month.start, + "end": planning_month.end, + "info": WEB_AVAILABILITY_INFO, + }, + ) + + def _insert_rows( + self, + *, + connection: Connection, + rows: tuple[TimeOfficeAvailabilityWriteRow, ...], + facts: TimeOfficeFacts, + ) -> None: + if not rows: + return + + query = text( + """ + INSERT INTO TPlanPersonalKommtGeht ( + RefPlan, + RefPersonal, + Datum, + RefStati, + lfdNr, + RefgAbw, + RefDienste, + RefBerufe, + RefPlanungseinheiten, + VonZeit, + BisZeit, + RefDienstAbw, + Minuten, + Info, + RefEinsatzArten, + Wunschdienst, + BereitVon, + BereitBis + ) + VALUES ( + :plan_id, + :employee_id, + :availability_date, + :status_id, + :sequence_number, + NULL, + :work_shift_id, + :profession_id, + :planning_unit_id, + NULL, + NULL, + :absence_shift_id, + 0, + :info, + NULL, + 0, + NULL, + NULL + ) + """ + ) + + connection.execute( + query, + self._insert_parameters( + connection=connection, + rows=rows, + facts=facts, + ), + ) + + def _insert_parameters( + self, + *, + connection: Connection, + rows: tuple[TimeOfficeAvailabilityWriteRow, ...], + facts: TimeOfficeFacts, + ) -> list[dict[str, object]]: + next_sequence_by_key: dict[tuple[int, int, Date], int] = {} + + parameters: list[dict[str, object]] = [] + + for row in rows: + sequence_key = ( + row.plan_id, + row.employee_id, + row.availability_date, + ) + + if sequence_key not in next_sequence_by_key: + next_sequence_by_key[sequence_key] = self._next_sequence_number( + connection=connection, + plan_id=row.plan_id, + employee_id=row.employee_id, + date_value=row.availability_date, + ) + + sequence_number = next_sequence_by_key[sequence_key] + next_sequence_by_key[sequence_key] += 1 + + parameters.append( + { + "plan_id": row.plan_id, + "employee_id": row.employee_id, + "availability_date": row.availability_date, + "status_id": facts.target_planning_status_id, + "sequence_number": sequence_number, + "work_shift_id": row.work_shift_id, + "profession_id": self._find_profession_id( + connection=connection, + employee_id=row.employee_id, + planning_unit_id=row.planning_unit_id, + ), + "planning_unit_id": row.planning_unit_id, + "absence_shift_id": row.absence_shift_id, + "info": WEB_AVAILABILITY_INFO, + } + ) + + return parameters + + def _find_target_plan_id( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + ) -> int: + query = text( + """ + SELECT TOP 1 + pkg.RefPlan AS plan_id, + COUNT(*) AS row_count + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPlanungseinheiten = :planning_unit_id + AND CONVERT(date, pkg.Datum) BETWEEN :start AND :end + GROUP BY pkg.RefPlan + ORDER BY row_count DESC + """ + ) + + row = ( + connection.execute( + query, + { + "planning_unit_id": planning_unit_id, + "start": planning_month.start, + "end": planning_month.end, + }, + ) + .mappings() + .first() + ) + + if row is None: + raise ValueError( + f"No TimeOffice plan found for planning_unit_id={planning_unit_id}, " + f"planning_month={planning_month.label}." + ) + + return int(row["plan_id"]) + + def _next_sequence_number( + self, + *, + connection: Connection, + plan_id: int, + employee_id: int, + date_value: object, + ) -> int: + query = text( + """ + SELECT COALESCE(MAX(pkg.lfdNr), 0) + 1 AS next_sequence_number + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPlan = :plan_id + AND pkg.RefPersonal = :employee_id + AND CONVERT(date, pkg.Datum) = :date_value + """ + ) + + row = ( + connection.execute( + query, + { + "plan_id": plan_id, + "employee_id": employee_id, + "date_value": date_value, + }, + ) + .mappings() + .one() + ) + + return int(row["next_sequence_number"]) + + def _find_profession_id( + self, + *, + connection: Connection, + employee_id: int, + planning_unit_id: int, + ) -> int: + query = text( + """ + SELECT TOP 1 + pkg.RefBerufe AS profession_id, + COUNT(*) AS usage_count + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPersonal = :employee_id + AND pkg.RefPlanungseinheiten = :planning_unit_id + AND pkg.RefBerufe IS NOT NULL + GROUP BY pkg.RefBerufe + ORDER BY usage_count DESC + """ + ) + + row = ( + connection.execute( + query, + { + "employee_id": employee_id, + "planning_unit_id": planning_unit_id, + }, + ) + .mappings() + .first() + ) + + if row is None: + raise ValueError( + f"No TimeOffice profession found for employee_id={employee_id}, planning_unit_id={planning_unit_id}." + ) + + return int(row["profession_id"]) + + def delete_employee_availability( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + ) -> None: + self._delete_web_availability_rows( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) From 4ecbebc16faa3332bbf2426877ca2874eb008d2c Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 15 Jul 2026 13:35:53 +0200 Subject: [PATCH 18/23] =?UTF-8?q?API=20infra=20f=C3=BCr=20Disnstpl=C3=A4ne?= =?UTF-8?q?=20anzeigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scheduling/api/app.py | 4 +++- src/scheduling/api/web/schedule_router.py | 24 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/scheduling/api/web/schedule_router.py diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index f92b2a07..e35b015a 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -10,6 +10,7 @@ from scheduling.api.solve.router import solve_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 @@ -67,11 +68,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app = FastAPI(title="Staff Scheduling API", lifespan=lifespan) -app.include_router(solve_router) 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.get("/status") diff --git a/src/scheduling/api/web/schedule_router.py b/src/scheduling/api/web/schedule_router.py new file mode 100644 index 00000000..5ec29dca --- /dev/null +++ b/src/scheduling/api/web/schedule_router.py @@ -0,0 +1,24 @@ +import logging +from datetime import date +from typing import Annotated + +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + + +schedule_router = APIRouter() + +# Returns the schedule generated by the solver for a given month and year. + + +@schedule_router.get("/schedules") +async def get_schedule( + planning_unit: int, + from_date: date, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> dict[str, list[dict[str, any]]]: + pass From 5d935ac696f5a6f6a32b9c027c73581fefe13921 Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Fri, 17 Jul 2026 12:33:17 +0200 Subject: [PATCH 19/23] Auskommentieren der Funktionen mit CREAtE Teilen damit das Readen der Dataset funktioniert. Viele funktionen ansonsten aufgrund der Datenbank ungetestet --- src/scheduling/api/web/schedule_router.py | 4 +- src/scheduling/timeoffice/facts.py | 1 + src/scheduling/timeoffice/reading/demand.py | 5 +- .../timeoffice/reading/objective_weights.py | 7 +- src/scheduling/timeoffice/writing/roster.py | 120 ++++++++++++++---- 5 files changed, 103 insertions(+), 34 deletions(-) diff --git a/src/scheduling/api/web/schedule_router.py b/src/scheduling/api/web/schedule_router.py index 5ec29dca..bd3f9237 100644 --- a/src/scheduling/api/web/schedule_router.py +++ b/src/scheduling/api/web/schedule_router.py @@ -1,6 +1,6 @@ import logging from datetime import date -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends @@ -20,5 +20,5 @@ async def get_schedule( planning_unit: int, from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], -) -> dict[str, list[dict[str, any]]]: +) -> dict[str, list[dict[str, Any]]]: pass diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index 0ab510b2..9da22cde 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -189,6 +189,7 @@ class TimeOfficeFacts: "A-81302-016": StaffLevel.TRAINEE, # A-Pflegefachkraft Kinderkrankenpflege "A-81302-018": StaffLevel.TRAINEE, # A-Pflegefachkraft Krankenpflege "A-81302-019": StaffLevel.TRAINEE, # A-Pflegefachkraft Altenpflege + "-": StaffLevel.TRAINEE, # Schauen was für eine Profession das ist } ) diff --git a/src/scheduling/timeoffice/reading/demand.py b/src/scheduling/timeoffice/reading/demand.py index 21d22447..3bbe970f 100644 --- a/src/scheduling/timeoffice/reading/demand.py +++ b/src/scheduling/timeoffice/reading/demand.py @@ -1,4 +1,4 @@ -from sqlalchemy import Connection, bindparam, text +from sqlalchemy import Connection, text from scheduling.domain.core import SchedulingBaseModel @@ -18,6 +18,7 @@ def read_minimal_staffing( connection: Connection, planning_unit_ids: tuple[int, ...], ) -> tuple[TimeOfficeDemandRow, ...]: + ''' self._ensure_minimal_staffing_table_exists(connection=connection) if not planning_unit_ids: @@ -51,6 +52,8 @@ def read_minimal_staffing( ) for row in rows ) + ''' + return () def _ensure_minimal_staffing_table_exists(self, *, connection: Connection) -> None: query = text( diff --git a/src/scheduling/timeoffice/reading/objective_weights.py b/src/scheduling/timeoffice/reading/objective_weights.py index a638e880..3cfddee5 100644 --- a/src/scheduling/timeoffice/reading/objective_weights.py +++ b/src/scheduling/timeoffice/reading/objective_weights.py @@ -1,4 +1,4 @@ -from sqlalchemy import Connection, bindparam, text +from sqlalchemy import Connection, text from scheduling.domain.core import SchedulingBaseModel @@ -16,7 +16,7 @@ def read_rows( connection: Connection, planning_unit_ids: tuple[int, ...], ) -> tuple[TimeOfficeObjectiveWeightRow, ...]: - self._ensure_objective_weights_table_exists(connection=connection) + '''self._ensure_objective_weights_table_exists(connection=connection) if not planning_unit_ids: return () @@ -44,7 +44,8 @@ def read_rows( weight=int(row["weight"]), ) for row in rows - ) + )''' + return () def _ensure_objective_weights_table_exists(self, *, connection: Connection) -> None: query = text( diff --git a/src/scheduling/timeoffice/writing/roster.py b/src/scheduling/timeoffice/writing/roster.py index becd4557..35f6ebe4 100644 --- a/src/scheduling/timeoffice/writing/roster.py +++ b/src/scheduling/timeoffice/writing/roster.py @@ -9,8 +9,6 @@ map_availabilities_to_timeoffice_rows, ) -WEB_AVAILABILITY_INFO = "StaffSchedulingWebAvailability" - class TimeOfficeAvailabilityWriter: def replace_employee_availability( @@ -29,7 +27,7 @@ def replace_employee_availability( planning_month=planning_month, ) - self._delete_web_availability_rows( + self._delete_employee_availability_rows( connection=connection, planning_unit_id=planning_unit_id, planning_month=planning_month, @@ -46,10 +44,24 @@ def replace_employee_availability( self._insert_rows( connection=connection, rows=rows, - facts=facts, ) - def _delete_web_availability_rows( + def delete_employee_availability( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + ) -> None: + self._delete_employee_availability_rows( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) + + def _delete_employee_availability_rows( self, *, connection: Connection, @@ -63,7 +75,13 @@ def _delete_web_availability_rows( WHERE RefPersonal = :employee_id AND RefPlanungseinheiten = :planning_unit_id AND CONVERT(date, Datum) BETWEEN :start AND :end - AND Info = :info + AND ISNULL(Wunschdienst, 0) = 0 + AND ( + RefgAbw IS NOT NULL + OR RefDienstAbw IS NOT NULL + OR BereitVon IS NOT NULL + OR BereitBis IS NOT NULL + ) """ ) @@ -74,7 +92,6 @@ def _delete_web_availability_rows( "planning_unit_id": planning_unit_id, "start": planning_month.start, "end": planning_month.end, - "info": WEB_AVAILABILITY_INFO, }, ) @@ -83,7 +100,6 @@ def _insert_rows( *, connection: Connection, rows: tuple[TimeOfficeAvailabilityWriteRow, ...], - facts: TimeOfficeFacts, ) -> None: if not rows: return @@ -104,7 +120,6 @@ def _insert_rows( BisZeit, RefDienstAbw, Minuten, - Info, RefEinsatzArten, Wunschdienst, BereitVon, @@ -124,7 +139,6 @@ def _insert_rows( NULL, :absence_shift_id, 0, - :info, NULL, 0, NULL, @@ -138,7 +152,6 @@ def _insert_rows( self._insert_parameters( connection=connection, rows=rows, - facts=facts, ), ) @@ -147,9 +160,10 @@ def _insert_parameters( *, connection: Connection, rows: tuple[TimeOfficeAvailabilityWriteRow, ...], - facts: TimeOfficeFacts, ) -> list[dict[str, object]]: next_sequence_by_key: dict[tuple[int, int, Date], int] = {} + profession_id_by_key: dict[tuple[int, int], int] = {} + status_id_by_key: dict[tuple[int, int, int], int] = {} parameters: list[dict[str, object]] = [] @@ -171,22 +185,43 @@ def _insert_parameters( sequence_number = next_sequence_by_key[sequence_key] next_sequence_by_key[sequence_key] += 1 + profession_key = ( + row.employee_id, + row.planning_unit_id, + ) + + if profession_key not in profession_id_by_key: + profession_id_by_key[profession_key] = self._find_profession_id( + connection=connection, + employee_id=row.employee_id, + planning_unit_id=row.planning_unit_id, + ) + + status_key = ( + row.plan_id, + row.employee_id, + row.planning_unit_id, + ) + + if status_key not in status_id_by_key: + status_id_by_key[status_key] = self._find_status_id_for_plan( + connection=connection, + plan_id=row.plan_id, + employee_id=row.employee_id, + planning_unit_id=row.planning_unit_id, + ) + parameters.append( { "plan_id": row.plan_id, "employee_id": row.employee_id, "availability_date": row.availability_date, - "status_id": facts.target_planning_status_id, + "status_id": status_id_by_key[status_key], "sequence_number": sequence_number, "work_shift_id": row.work_shift_id, - "profession_id": self._find_profession_id( - connection=connection, - employee_id=row.employee_id, - planning_unit_id=row.planning_unit_id, - ), + "profession_id": profession_id_by_key[profession_key], "planning_unit_id": row.planning_unit_id, "absence_shift_id": row.absence_shift_id, - "info": WEB_AVAILABILITY_INFO, } ) @@ -306,17 +341,46 @@ def _find_profession_id( return int(row["profession_id"]) - def delete_employee_availability( + def _find_status_id_for_plan( self, *, connection: Connection, - planning_unit_id: int, - planning_month: PlanningMonth, + plan_id: int, employee_id: int, - ) -> None: - self._delete_web_availability_rows( - connection=connection, - planning_unit_id=planning_unit_id, - planning_month=planning_month, - employee_id=employee_id, + planning_unit_id: int, + ) -> int: + query = text( + """ + SELECT TOP 1 + pkg.RefStati AS status_id, + COUNT(*) AS usage_count + FROM TPlanPersonalKommtGeht pkg + WHERE pkg.RefPlan = :plan_id + AND pkg.RefPersonal = :employee_id + AND pkg.RefPlanungseinheiten = :planning_unit_id + AND pkg.RefStati IS NOT NULL + GROUP BY pkg.RefStati + ORDER BY usage_count DESC + """ ) + + row = ( + connection.execute( + query, + { + "plan_id": plan_id, + "employee_id": employee_id, + "planning_unit_id": planning_unit_id, + }, + ) + .mappings() + .first() + ) + + if row is None: + raise ValueError( + f"No RefStati found for plan_id={plan_id}, employee_id={employee_id}, " + f"planning_unit_id={planning_unit_id}." + ) + + return int(row["status_id"]) From a592d63e7bb0e7c05edd53a486e4c906b5d2968e Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Fri, 17 Jul 2026 12:44:46 +0200 Subject: [PATCH 20/23] =?UTF-8?q?Neue=20TimeOfficeServiceParameter=20auch?= =?UTF-8?q?=20in=20den=20Service=20in=20der=20Main=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main.py b/src/main.py index 915fba80..92753c96 100644 --- a/src/main.py +++ b/src/main.py @@ -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: @@ -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, From f32594c7a77eea760118bf4eab46f30988733039 Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Fri, 17 Jul 2026 13:21:40 +0200 Subject: [PATCH 21/23] =?UTF-8?q?Fix=20f=C3=BCr=20pyright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scheduling/api/web/schedule_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scheduling/api/web/schedule_router.py b/src/scheduling/api/web/schedule_router.py index bd3f9237..5305de14 100644 --- a/src/scheduling/api/web/schedule_router.py +++ b/src/scheduling/api/web/schedule_router.py @@ -21,4 +21,4 @@ async def get_schedule( from_date: date, timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], ) -> dict[str, list[dict[str, Any]]]: - pass + return {"schedules": []} From 83dbed069fe9ed52489e705d18a53241d839c8cd Mon Sep 17 00:00:00 2001 From: Lena Giebeler Date: Sat, 18 Jul 2026 10:47:28 +0200 Subject: [PATCH 22/23] Fixed some Pyright checks --- src/scheduling/timeoffice/facts.py | 6 +++++ src/scheduling/timeoffice/writing/wishes.py | 25 +++++++++++++++++++++ tests/test_timeoffice_solution_writer.py | 16 ++++++------- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/scheduling/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index 9da22cde..2fcc0fa3 100644 --- a/src/scheduling/timeoffice/facts.py +++ b/src/scheduling/timeoffice/facts.py @@ -100,6 +100,7 @@ class TimeOfficeFacts: ignored_availability_absence_codes: frozenset[str] wish_type_by_absence_code: Mapping[str, WishType] + wish_absence_shift_id_by_type: Mapping[WishType, int] availability_absence_shift_id_by_type: Mapping[AvailabilityType, int] @@ -297,6 +298,11 @@ class TimeOfficeFacts: "FR": WishType.FREE_DAY, } ), + wish_absence_shift_id_by_type=MappingProxyType( + { + WishType.FREE_DAY: 1089, + } + ), monthly_target_work_account_id=MONTHLY_TARGET_WORK_ACCOUNT_ID, monthly_actual_work_account_id=MONTHLY_ACTUAL_WORK_ACCOUNT_ID, ) diff --git a/src/scheduling/timeoffice/writing/wishes.py b/src/scheduling/timeoffice/writing/wishes.py index 238ce594..52c192c7 100644 --- a/src/scheduling/timeoffice/writing/wishes.py +++ b/src/scheduling/timeoffice/writing/wishes.py @@ -286,3 +286,28 @@ def delete_employee_wishes( "end": planning_month.end, }, ) + + def replace_employee_wishes( + self, + *, + connection: Connection, + planning_unit_id: int, + planning_month: PlanningMonth, + employee_id: int, + wishes: tuple[Wish, ...], + facts: TimeOfficeFacts, + ) -> None: + self.delete_employee_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + ) + + self.insert_wishes( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + wishes=wishes, + facts=facts, + ) diff --git a/tests/test_timeoffice_solution_writer.py b/tests/test_timeoffice_solution_writer.py index cb7ec1fd..89df8a0d 100644 --- a/tests/test_timeoffice_solution_writer.py +++ b/tests/test_timeoffice_solution_writer.py @@ -16,7 +16,7 @@ StaffLevel, ) from scheduling.solver.models import Solution, SolutionStatus -from scheduling.timeoffice.facts import EARLY_F2_SHIFT_ID, LATE_S2_SHIFT_ID +from scheduling.timeoffice.facts import EARLY_SHIFT_ID, LATE_SHIFT_ID from scheduling.timeoffice.writing.solution import TimeOfficeSolutionWriter, build_legacy_solution_data @@ -29,7 +29,7 @@ def test_build_legacy_solution_data_uses_dense_legacy_variable_format() -> None: employee_id=102, planning_unit_id=77, date=date(2024, 11, 2), - shift_id=LATE_S2_SHIFT_ID, + shift_id=LATE_SHIFT_ID, assignment_type=AssignmentType.GENERATED, ), ), @@ -46,7 +46,7 @@ def test_build_legacy_solution_data_uses_dense_legacy_variable_format() -> None: assert data["variables"]["e:101_d:2024-11-01"] == 1 assert data["variables"]["e:101_d:2024-11-02"] == 0 assert data["variables"]["e:102_d:2024-11-02"] == 1 - assert f"(102, '2024-11-02', {LATE_S2_SHIFT_ID})" not in data["variables"] + assert f"(102, '2024-11-02', {LATE_SHIFT_ID})" not in data["variables"] def test_write_legacy_format_writes_legacy_solution_json(tmp_path: Path) -> None: @@ -109,8 +109,8 @@ def _dataset() -> SchedulingDataset: plans=(), shifts=( Shift( - shift_id=EARLY_F2_SHIFT_ID, - code="F2_", + shift_id=EARLY_SHIFT_ID, + code="F", type=ShiftType.EARLY, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, start_minute=360, @@ -118,8 +118,8 @@ def _dataset() -> SchedulingDataset: net_work_minutes=460, ), Shift( - shift_id=LATE_S2_SHIFT_ID, - code="S2_", + shift_id=LATE_SHIFT_ID, + code="S", type=ShiftType.LATE, staffing_role=StaffingDemandRole.REQUIRED_MINIMUM, start_minute=805, @@ -144,7 +144,7 @@ def _dataset() -> SchedulingDataset: employee_id=101, planning_unit_id=77, date=date(2024, 11, 1), - shift_id=EARLY_F2_SHIFT_ID, + shift_id=EARLY_SHIFT_ID, assignment_type=AssignmentType.PLANNED, ), ), From de0d6b38b40f7f4a2fe7cbe00d354c97dae9f26a Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 19 Jul 2026 00:19:55 +0200 Subject: [PATCH 23/23] schedule api get and api solution changed to local save --- src/scheduling/api/solve/router.py | 16 +++++++++-- src/scheduling/api/web/schedule_router.py | 34 ++++++++++++++++------- src/scheduling/timeoffice/service.py | 15 ++++++++++ 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/scheduling/api/solve/router.py b/src/scheduling/api/solve/router.py index 742b20ba..d5b2e56d 100644 --- a/src/scheduling/api/solve/router.py +++ b/src/scheduling/api/solve/router.py @@ -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 diff --git a/src/scheduling/api/web/schedule_router.py b/src/scheduling/api/web/schedule_router.py index 5305de14..615a6fa8 100644 --- a/src/scheduling/api/web/schedule_router.py +++ b/src/scheduling/api/web/schedule_router.py @@ -1,24 +1,38 @@ +import json import logging from datetime import date -from typing import Annotated, Any +from pathlib import Path +from typing import Any -from fastapi import APIRouter, Depends - -from scheduling.api.dependencies import get_timeoffice_service -from scheduling.timeoffice.service import TimeOfficeService +from fastapi import APIRouter logger = logging.getLogger(__name__) +PROCESSED_SOLUTIONS_DIR = Path("processed_solutions") schedule_router = APIRouter() -# Returns the schedule generated by the solver for a given month and year. +# 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_router.get("/schedules/{schedule_id}") async def get_schedule( planning_unit: int, from_date: date, - timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], -) -> dict[str, list[dict[str, Any]]]: - return {"schedules": []} + 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) diff --git a/src/scheduling/timeoffice/service.py b/src/scheduling/timeoffice/service.py index 2b2c04a8..a65c981c 100644 --- a/src/scheduling/timeoffice/service.py +++ b/src/scheduling/timeoffice/service.py @@ -1,4 +1,5 @@ import logging +from typing import Any from sqlalchemy import Engine @@ -227,3 +228,17 @@ def replace_objective_weights( planning_unit_id=planning_unit_id, objective_weights=objective_weights, ) + + def get_solution_data( + self, + *, + planning_unit_id: int, + planning_month: PlanningMonth, + schedule_id: str, + ) -> dict[str, Any] | None: + """Retrieve processed solution data for a given schedule. + + TODO: Implement actual database access to fetch the persisted solution. + For now this is a stub that returns None (no solution found). + """ + return None