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, diff --git a/src/scheduling/api/app.py b/src/scheduling/api/app.py index 12d1a836..e35b015a 100644 --- a/src/scheduling/api/app.py +++ b/src/scheduling/api/app.py @@ -8,7 +8,11 @@ from scheduling.api.dependencies import ApiRuntime from scheduling.api.solve.job_store import InMemorySolveJobStore from scheduling.api.solve.router import solve_router -from scheduling.api.web.router import web_router +from scheduling.api.web.employee_router import employee_router +from scheduling.api.web.minimal_staff_router import minimal_staff_router +from scheduling.api.web.schedule_router import schedule_router +from scheduling.api.web.weights_router import weights_router +from scheduling.api.web.wishes_availabilities_router import wishes_and_availabilities_router from scheduling.logging import configure_logging from scheduling.settings import get_settings from scheduling.solver.cp_sat.builder import create_cp_sat_model_builder @@ -17,7 +21,11 @@ from scheduling.timeoffice.facts import TIMEOFFICE_FACTS from scheduling.timeoffice.reading.container import TimeOfficeReaders from scheduling.timeoffice.service import TimeOfficeService +from scheduling.timeoffice.writing.demand import TimeOfficeDemandWriter +from scheduling.timeoffice.writing.objective_weights import TimeOfficeWeightsWriter +from scheduling.timeoffice.writing.roster import TimeOfficeAvailabilityWriter from scheduling.timeoffice.writing.solution import TimeOfficeSolutionWriter +from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter settings = get_settings() configure_logging(level=settings.log_level) @@ -38,6 +46,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: engine=engine, readers=TimeOfficeReaders.create(facts=facts), solution_writer=TimeOfficeSolutionWriter(), + wish_writer=TimeOfficeWishWriter( + target_planning_status_id=facts.target_planning_status_id, + ), + demand_writer=TimeOfficeDemandWriter(), + objective_weights_writer=TimeOfficeWeightsWriter(), + availability_writer=TimeOfficeAvailabilityWriter(), ), solver_service=SolverService( settings=settings, @@ -54,8 +68,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app = FastAPI(title="Staff Scheduling API", lifespan=lifespan) +app.include_router(employee_router) +app.include_router(weights_router) +app.include_router(wishes_and_availabilities_router) +app.include_router(minimal_staff_router) +app.include_router(schedule_router) app.include_router(solve_router) -app.include_router(web_router) @app.get("/status") 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/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/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..2d92aab2 --- /dev/null +++ b/src/scheduling/api/web/minimal_staff_router.py @@ -0,0 +1,185 @@ +import logging +from datetime import date, timedelta +from typing import Annotated + +from fastapi import APIRouter, Depends + +from scheduling.api.dependencies import get_timeoffice_service +from scheduling.api.web.schemas import SuccessResponse, UpdateMinimalStaffRequest +from scheduling.domain import DemandRequirement, PlanningMonth, StaffLevel +from scheduling.timeoffice.facts import TIMEOFFICE_FACTS +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + + +minimal_staff_router = APIRouter() + +DAYS_OF_WEEK = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") + +FRONTEND_STAFF_LEVEL_BY_DOMAIN = { + StaffLevel.TRAINEE: "Azubi", + StaffLevel.PROFESSIONAL: "Fachkraft", + StaffLevel.ASSISTANT: "Hilfskraft", +} + +DOMAIN_STAFF_LEVEL_BY_FRONTEND = { + "Azubi": StaffLevel.TRAINEE, + "Fachkraft": StaffLevel.PROFESSIONAL, + "Hilfskraft": StaffLevel.ASSISTANT, +} + +FRONTEND_STAFF_LEVELS = ("Azubi", "Fachkraft", "Hilfskraft") + +FRONTEND_SHIFT_CODES = ("F", "S", "N", "Z") + +WEEKDAY_TO_ID = { + "Mo": 1, + "Di": 2, + "Mi": 3, + "Do": 4, + "Fr": 5, + "Sa": 6, + "So": 7, +} + + +@minimal_staff_router.get("/minimal-staff") +async def get_minimal_staff( + planning_unit: int, + from_date: date, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> dict[str, dict[str, dict[str, int]]]: + month = PlanningMonth(year=from_date.year, month=from_date.month) + + dataset = timeoffice.fetch_dataset( + planning_unit_ids=(planning_unit,), + planning_month=month, + ) + + return _minimal_staff_to_frontend(dataset.demand_requirements) + + +def _minimal_staff_to_frontend( + demand_requirements: tuple[DemandRequirement, ...], +) -> dict[str, dict[str, dict[str, int]]]: + output = _empty_minimal_staff_response() + + for requirement in demand_requirements: + staff_level = FRONTEND_STAFF_LEVEL_BY_DOMAIN[requirement.staff_level] + day = DAYS_OF_WEEK[requirement.date.isoweekday() - 1] + shift_code = _shift_code_from_shift_id(requirement.shift_id) + + output[staff_level][day][shift_code] = requirement.required_count + + return output + + +def _empty_minimal_staff_response() -> dict[str, dict[str, dict[str, int]]]: + return { + staff_level: {day: dict.fromkeys(FRONTEND_SHIFT_CODES, 0) for day in DAYS_OF_WEEK} + for staff_level in FRONTEND_STAFF_LEVELS + } + + +def _shift_code_from_shift_id(shift_id: int) -> str: + shift_fact = TIMEOFFICE_FACTS.reference_shift_facts_by_id.get(shift_id) + + if shift_fact is None: + raise ValueError(f"Unknown reference shift_id={shift_id}.") + + if shift_fact.expected_code not in FRONTEND_SHIFT_CODES: + raise ValueError(f"Unsupported minimal staff shift code={shift_fact.expected_code!r} for shift_id={shift_id}.") + + return shift_fact.expected_code + + +@minimal_staff_router.put("/minimal-staff") +async def put_minimal_staff( + planning_unit: int, + from_date: date, + request: UpdateMinimalStaffRequest, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> SuccessResponse: + planning_month = PlanningMonth(year=from_date.year, month=from_date.month) + + demand_requirements = _minimal_staff_request_to_domain( + planning_unit=planning_unit, + planning_month=planning_month, + minimal_staff=request.data, + ) + + timeoffice.replace_minimal_staffing( + planning_unit_id=planning_unit, + demand_requirements=demand_requirements, + ) + + return SuccessResponse() + + +def _minimal_staff_request_to_domain( + *, + planning_unit: int, + planning_month: PlanningMonth, + minimal_staff: dict[str, dict[str, dict[str, int]]], +) -> tuple[DemandRequirement, ...]: + requirements: list[DemandRequirement] = [] + + for frontend_staff_level, demand_by_day in minimal_staff.items(): + staff_level = DOMAIN_STAFF_LEVEL_BY_FRONTEND[frontend_staff_level] + + for frontend_day, demand_by_shift in demand_by_day.items(): + weekday_iso = WEEKDAY_TO_ID[frontend_day] + + for shift_code, minimum_count in demand_by_shift.items(): + if minimum_count < 0: + raise ValueError("Minimal staff count must be >=0") + + if minimum_count == 0: + continue + + shift_id = _shift_id_from_shift_code(shift_code) + + for requirement_date in _dates_for_weekday( + planning_month=planning_month, + weekday_iso=weekday_iso, + ): + requirements.append( + DemandRequirement( + planning_unit_id=planning_unit, + date=requirement_date, + shift_id=shift_id, + staff_level=staff_level, + required_count=minimum_count, + ) + ) + + return tuple(requirements) + + +def _shift_id_from_shift_code(shift_code: str) -> int: + if shift_code not in FRONTEND_SHIFT_CODES: + raise ValueError(f"Shift Code is not defined in frontend: shift_code={shift_code}.") + + for shift_id, shift_fact in TIMEOFFICE_FACTS.reference_shift_facts_by_id.items(): + if shift_fact.expected_code == shift_code: + return shift_id + + raise ValueError(f"Unknown shift_code={shift_code}.") + + +def _dates_for_weekday( + *, + planning_month: PlanningMonth, + weekday_iso: int, +) -> tuple[date, ...]: + dates: list[date] = [] + + current_date = planning_month.start + while current_date <= planning_month.end: + if current_date.isoweekday() == weekday_iso: + dates.append(current_date) + + current_date += timedelta(days=1) + + return tuple(dates) diff --git a/src/scheduling/api/web/schedule_router.py b/src/scheduling/api/web/schedule_router.py new file mode 100644 index 00000000..615a6fa8 --- /dev/null +++ b/src/scheduling/api/web/schedule_router.py @@ -0,0 +1,38 @@ +import json +import logging +from datetime import date +from pathlib import Path +from typing import Any + +from fastapi import APIRouter + +logger = logging.getLogger(__name__) + +PROCESSED_SOLUTIONS_DIR = Path("processed_solutions") + +schedule_router = APIRouter() + +# TODO: change solve router to use the lagacy solution writer already present in timeoffice +# TODO: adapt the get_schedule acordingly, +# TODO: use _get_solution_legacy to fetch the data so, when the db works, we only need to relace that one function + + +@schedule_router.get("/schedules/{schedule_id}") +async def get_schedule( + planning_unit: int, + from_date: date, + schedule_id: str, +) -> Any: + """Return a schedule solution for a planning unit and month.""" + return _get_solution_legacy(schedule_id) + + +def _get_solution_legacy(schedule_id: str) -> Any: + """Read a processed solution from the legacy JSON files, place holder until database read write.""" + solution_path = PROCESSED_SOLUTIONS_DIR / f"{schedule_id}_processed.json" + + if not solution_path.is_file(): + return {"solution": None} + + with solution_path.open(encoding="utf-8") as f: + return json.load(f) diff --git a/src/scheduling/api/web/schemas.py b/src/scheduling/api/web/schemas.py new file mode 100644 index 00000000..6013bc19 --- /dev/null +++ b/src/scheduling/api/web/schemas.py @@ -0,0 +1,51 @@ +from pydantic import Field + +from scheduling.domain.core import SchedulingBaseModel + + +class WishesAndBlockedEmployeeRequest(SchedulingBaseModel): + key: int + firstname: str | None = None + name: str | None = None + + 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, ...] + + +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 new file mode 100644 index 00000000..fc586ab0 --- /dev/null +++ b/src/scheduling/api/web/weights_router.py @@ -0,0 +1,102 @@ +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.api.web.schemas import SuccessResponse, UpdateWeightsRequest, WeightsRequestData +from scheduling.domain import PlanningMonth, SolverObjectiveWeights +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + +weights_router = APIRouter() + + +@weights_router.get("/weights") +async def get_weights( + planning_unit: int, + from_date: date, + timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)], +) -> 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, + ) + + 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, # 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, + ) diff --git a/src/scheduling/api/web/wishes_availabilities_router.py b/src/scheduling/api/web/wishes_availabilities_router.py new file mode 100644 index 00000000..535f992b --- /dev/null +++ b/src/scheduling/api/web/wishes_availabilities_router.py @@ -0,0 +1,316 @@ +import json +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 Availability, AvailabilityType, Employee, PlanningMonth, Wish, WishType +from scheduling.timeoffice.facts import TIMEOFFICE_FACTS +from scheduling.timeoffice.service import TimeOfficeService + +logger = logging.getLogger(__name__) + +wishes_and_availabilities_router = APIRouter() + + +@wishes_and_availabilities_router.get("/wishes-and-blocked") +async def get_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 + month = PlanningMonth(year=from_date_year, month=from_date_month) + + dataset = timeoffice.fetch_dataset( + planning_unit_ids=(planning_unit,), + planning_month=month, + ) + + employee_wishes_blocked = [ + _wishes_and_availability_to_frontend( + employee=employee, + wishes=dataset.wishes, + availability=dataset.availability, + ) + 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": [ + employee_wish_block + for employee_wish_block in employee_wishes_blocked + if _has_any_wishes_or_availability(employee_wish_block) + ] + } + + +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, + # 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.FREE_SHIFT + ], + "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.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.") + + 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_and_availabilities_router.put("/wishes-and-blocked/{employee_id}") +async def replace_wishes_and_blocked( + employee_id: int, + planning_unit: 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=from_date.year, month=from_date.month) + + wishes = _wishes_employee_request_to_domain( + employee=request.data, + planning_unit=planning_unit, + planning_month=planning_month, + ) + + 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() + + +def _wishes_employee_request_to_domain( + *, + employee: WishesAndBlockedEmployeeRequest, + planning_unit: int, + planning_month: PlanningMonth, +) -> tuple[Wish, ...]: + wishes: list[Wish] = [] + + 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.FREE_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.FREE_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + 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.PREFERRED_DAY, + ) + ) + + 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.PREFERRED_SHIFT, + shift_id=_shift_id_from_frontend(shift_code), + ) + ) + + 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: + return shift_id + + raise ValueError(f"Unknown shift code from wishes frontend: {shift_code!r}.") + + +@wishes_and_availabilities_router.delete("/wishes-and-blocked/{employee_id}") +async def delete_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_and_availability( + 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 a22e1b50..05f9ba94 100644 --- a/src/scheduling/domain/__init__.py +++ b/src/scheduling/domain/__init__.py @@ -1,11 +1,13 @@ 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.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 from scheduling.domain.shift import Shift, ShiftId, ShiftType, StaffingDemandRole from scheduling.domain.sunday_work_history import EmployeeSundayWorkHistory @@ -42,4 +44,5 @@ "Wish", "WishType", "MonthlyWorkAccount", + "SolverObjectiveWeights", ] diff --git a/src/scheduling/domain/dataset.py b/src/scheduling/domain/dataset.py index a32d8a32..2811f09a 100644 --- a/src/scheduling/domain/dataset.py +++ b/src/scheduling/domain/dataset.py @@ -1,44 +1,18 @@ -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 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 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}" - - class SchedulingDataset(SchedulingBaseModel): """Clean scheduling dataset aligned with TimeOffice planning concepts. @@ -63,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/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/timeoffice/facts.py b/src/scheduling/timeoffice/facts.py index c315ba0c..2fcc0fa3 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) @@ -97,6 +100,9 @@ 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] monthly_target_work_account_id: int monthly_actual_work_account_id: int @@ -104,31 +110,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, - ), } ) @@ -137,18 +138,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 - # 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 } ) @@ -189,6 +190,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 } ) @@ -202,23 +204,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), } ), } @@ -269,21 +271,26 @@ 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 + } + ), + 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. # 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( @@ -291,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/mapping/dataset.py b/src/scheduling/timeoffice/mapping/dataset.py index 1a3a0b9c..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 @@ -44,13 +45,14 @@ 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), - wishes=map_wishes( - rows=sources.wish_rows, - shifts=shifts, - facts=facts, - ), + 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/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/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/mapping/wishes.py b/src/scheduling/timeoffice/mapping/wishes.py index 2649a907..65c70e4c 100644 --- a/src/scheduling/timeoffice/mapping/wishes.py +++ b/src/scheduling/timeoffice/mapping/wishes.py @@ -1,6 +1,8 @@ +from collections import defaultdict from datetime import date as Date 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 @@ -23,7 +25,10 @@ def map_wishes( for row in rows ) - return _deduplicate_wishes(wishes) + wishes = _deduplicate_wishes(wishes) + wishes = _collapse_preferred_day_wishes(wishes, facts=facts) + + return _sort_wishes(wishes) def _map_wish( @@ -32,6 +37,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, @@ -42,6 +54,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, @@ -156,3 +202,85 @@ 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 _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, + ), + ) + ) diff --git a/src/scheduling/timeoffice/reading/container.py b/src/scheduling/timeoffice/reading/container.py index cf923a5d..49e3e4de 100644 --- a/src/scheduling/timeoffice/reading/container.py +++ b/src/scheduling/timeoffice/reading/container.py @@ -4,6 +4,8 @@ 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, @@ -37,8 +39,10 @@ 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, ...] + objective_weight_rows: tuple[TimeOfficeObjectiveWeightRow, ...] @dataclass(frozen=True, slots=True) @@ -51,6 +55,8 @@ class TimeOfficeReaders: wishes: TimeOfficeWishReader sunday_work_history: TimeOfficeSundayWorkHistoryReader monthly_work_accounts: TimeOfficeMonthlyWorkAccountReader + demand: TimeOfficeDemandReader + weights: TimeOfficeWeightsReader @classmethod def create(cls, *, facts: TimeOfficeFacts) -> "TimeOfficeReaders": @@ -63,6 +69,8 @@ def create(cls, *, facts: TimeOfficeFacts) -> "TimeOfficeReaders": wishes=TimeOfficeWishReader(), sunday_work_history=TimeOfficeSundayWorkHistoryReader(), monthly_work_accounts=TimeOfficeMonthlyWorkAccountReader(facts=facts), + demand=TimeOfficeDemandReader(), + weights=TimeOfficeWeightsReader(), ) def read_sources( @@ -130,6 +138,16 @@ def read_sources( planning_month=planning_month, ) + demand_rows = self.demand.read_minimal_staffing( + connection=connection, + 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, @@ -140,6 +158,8 @@ 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, + objective_weight_rows=objective_weight_rows, ) diff --git a/src/scheduling/timeoffice/reading/demand.py b/src/scheduling/timeoffice/reading/demand.py new file mode 100644 index 00000000..3bbe970f --- /dev/null +++ b/src/scheduling/timeoffice/reading/demand.py @@ -0,0 +1,108 @@ +from sqlalchemy import Connection, 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 + ) + ''' + return () + + 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/reading/objective_weights.py b/src/scheduling/timeoffice/reading/objective_weights.py new file mode 100644 index 00000000..3cfddee5 --- /dev/null +++ b/src/scheduling/timeoffice/reading/objective_weights.py @@ -0,0 +1,92 @@ +from sqlalchemy import Connection, 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 + )''' + return () + + 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/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/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/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/remapping/wishes.py b/src/scheduling/timeoffice/remapping/wishes.py new file mode 100644 index 00000000..289cd5e6 --- /dev/null +++ b/src/scheduling/timeoffice/remapping/wishes.py @@ -0,0 +1,112 @@ +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: + 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}") + + +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..a65c981c 100644 --- a/src/scheduling/timeoffice/service.py +++ b/src/scheduling/timeoffice/service.py @@ -1,15 +1,27 @@ import logging +from typing import Any from sqlalchemy import Engine from scheduling.api.solve.schemas import SolveOptions -from scheduling.domain import PlanningMonth, SchedulingDataset +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 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.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 logger = logging.getLogger(__name__) @@ -25,11 +37,19 @@ def __init__( engine: Engine, readers: TimeOfficeReaders, solution_writer: TimeOfficeSolutionWriter, + wish_writer: TimeOfficeWishWriter, + demand_writer: TimeOfficeDemandWriter, + objective_weights_writer: TimeOfficeWeightsWriter, + availability_writer: TimeOfficeAvailabilityWriter, ) -> 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 + self._availability_writer = availability_writer def get_solve_options(self) -> SolveOptions: logger.info("Fetching TimeOffice solve options") @@ -132,3 +152,93 @@ def _normalize_planning_unit_ids( ) return normalized + + 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.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._availability_writer.replace_employee_availability( + connection=connection, + planning_unit_id=planning_unit_id, + planning_month=planning_month, + employee_id=employee_id, + availabilities=availabilities, + facts=self._facts, + ) + + def delete_employee_wishes_and_availability( + 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, + ) + + 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, + *, + 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, + ) + + 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 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) diff --git a/src/scheduling/timeoffice/writing/roster.py b/src/scheduling/timeoffice/writing/roster.py new file mode 100644 index 00000000..35f6ebe4 --- /dev/null +++ b/src/scheduling/timeoffice/writing/roster.py @@ -0,0 +1,386 @@ +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, +) + + +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_employee_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, + ) + + 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, + 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 ISNULL(Wunschdienst, 0) = 0 + AND ( + RefgAbw IS NOT NULL + OR RefDienstAbw IS NOT NULL + OR BereitVon IS NOT NULL + OR BereitBis IS NOT NULL + ) + """ + ) + + connection.execute( + query, + { + "employee_id": employee_id, + "planning_unit_id": planning_unit_id, + "start": planning_month.start, + "end": planning_month.end, + }, + ) + + def _insert_rows( + self, + *, + connection: Connection, + rows: tuple[TimeOfficeAvailabilityWriteRow, ...], + ) -> None: + if not rows: + return + + query = text( + """ + INSERT INTO TPlanPersonalKommtGeht ( + RefPlan, + RefPersonal, + Datum, + RefStati, + lfdNr, + RefgAbw, + RefDienste, + RefBerufe, + RefPlanungseinheiten, + VonZeit, + BisZeit, + RefDienstAbw, + Minuten, + 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, + NULL, + 0, + NULL, + NULL + ) + """ + ) + + connection.execute( + query, + self._insert_parameters( + connection=connection, + rows=rows, + ), + ) + + def _insert_parameters( + self, + *, + connection: Connection, + rows: tuple[TimeOfficeAvailabilityWriteRow, ...], + ) -> 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]] = [] + + 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 + + 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": status_id_by_key[status_key], + "sequence_number": sequence_number, + "work_shift_id": row.work_shift_id, + "profession_id": profession_id_by_key[profession_key], + "planning_unit_id": row.planning_unit_id, + "absence_shift_id": row.absence_shift_id, + } + ) + + 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 _find_status_id_for_plan( + self, + *, + connection: Connection, + plan_id: int, + employee_id: int, + 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"]) 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], ...] = ( diff --git a/src/scheduling/timeoffice/writing/wishes.py b/src/scheduling/timeoffice/writing/wishes.py new file mode 100644 index 00000000..52c192c7 --- /dev/null +++ b/src/scheduling/timeoffice/writing/wishes.py @@ -0,0 +1,313 @@ +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"]) + + 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, + }, + ) + + 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, ), ),