Free days, vacation forfeiture, and global auto-replanning
Free days (Sat/Sun/red days, subject to DayOverride) can never be vacation, and can only be parental leave when the workspace opts in via take_pl_on_red_days. The allocator already honoured this; the hand-pin path bypassed it entirely. - PlanningService gains optional workspace_repo/day_override_repo deps and _reject_illegal_free_day, enforced in add_planned_day and update. Reuses allocation_service.is_free_day rather than re-deriving it, and no-ops when the deps are unset so existing callers are unaffected. - update_planned_day had no ValidationError handler at all, so a validation failure there 500'd instead of 422'ing. Fixed. - Calendar filters the pin write loop by the day's isFree; the modal explains free days stay off, and that pinning opts a day out of automatic re-planning. Vacation days at risk of forfeiture were computed and sent over the wire but never rendered: an at-risk column with the age-out/over-cap split, and a dashboard key figure. PlanProposal.warnings was likewise parsed but rendered nowhere, for all three kinds. Adds PlanWarningCard in a Risks section above the tips, with parent names resolved and dates formatted. POST /api/plan/sync now fires from the workspace layer on every plan-input change rather than only from Calendar, so a Settings edit or a pinned-day save re-plans day types instead of merely refetching. Overlapping bumps coalesce into one trailing run; a rejection cannot wedge the in-flight guard. eviction_preview was silently dropped by the frontend despite always being on the wire, and is now surfaced through context. Verified: backend 890 passed (91.49% coverage), frontend 1297 passed, ruff/eslint/tsc clean. Browser-checked in Swedish and English. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
This commit is contained in:
@@ -440,6 +440,8 @@ def get_planning_service(
|
||||
planning_engine_service: PlanningEngineServiceDep,
|
||||
leave_wish_repo: LeaveWishRepositoryDep,
|
||||
uow: UnitOfWorkDep,
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
day_override_repo: DayOverrideRepositoryDep,
|
||||
) -> PlanningService:
|
||||
return PlanningService(
|
||||
repo,
|
||||
@@ -449,6 +451,8 @@ def get_planning_service(
|
||||
planning_engine_service,
|
||||
leave_wish_repo=leave_wish_repo,
|
||||
uow=uow,
|
||||
workspace_repo=workspace_repo,
|
||||
day_override_repo=day_override_repo,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,23 @@ def _to_response(day) -> PlannedLeaveDayResponse:
|
||||
return PlannedLeaveDayResponse.from_entity(day)
|
||||
|
||||
|
||||
def _to_http_error(exc: ValidationError) -> HTTPException:
|
||||
"""Map a `ValidationError` to a 422, forwarding its `message_key` + `data`
|
||||
|
||||
(§3.5) as structured `detail` when present — e.g. the free-day hand-pin
|
||||
guard (`PlanningService._reject_illegal_free_day`) — so the frontend can
|
||||
translate it instead of parsing an English sentence. Falls back to the
|
||||
plain message for the (more common) error raised without one. Mirrors
|
||||
`app.routers.workspace._to_http_error`.
|
||||
"""
|
||||
if exc.message_key:
|
||||
return HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"message_key": exc.message_key, "data": exc.data},
|
||||
)
|
||||
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("", response_model=PlannedLeaveDayResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_planned_day(
|
||||
payload: PlannedLeaveDayCreate,
|
||||
@@ -41,7 +58,7 @@ async def create_planned_day(
|
||||
)
|
||||
return _to_response(day)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
|
||||
raise _to_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.get("", response_model=list[PlannedLeaveDayResponse])
|
||||
@@ -96,6 +113,8 @@ async def update_planned_day(
|
||||
return _to_response(day)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise _to_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.delete("/{planned_leave_day_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -8,14 +8,22 @@ from dateutil.relativedelta import relativedelta
|
||||
|
||||
from app.config import settings
|
||||
from app.entities.planned_leave import PlannedLeaveDay
|
||||
from app.repositories.day_override_repository import DayOverrideRepository
|
||||
from app.repositories.leave_wish_repository import LeaveWishRepository
|
||||
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
||||
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
||||
from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork
|
||||
from app.repositories.vacation_days_repository import VacationDaysRepository
|
||||
from app.services.allocation_service import PlanningEngineService, PlanProposal, WindowOutcome
|
||||
from app.repositories.workspace_repository import WorkspaceRepository
|
||||
from app.services.allocation_service import (
|
||||
PlanningEngineService,
|
||||
PlanProposal,
|
||||
WindowOutcome,
|
||||
is_free_day,
|
||||
)
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
from app.utils.errors import DubbeldagError, NotFoundError, ValidationError
|
||||
from app.utils.holidays import get_swedish_holidays
|
||||
from app.utils.tip_predicates import default_tier_pool_share
|
||||
|
||||
# The two day-type buckets a persisted day can belong to for eviction
|
||||
@@ -215,6 +223,8 @@ class PlanningService:
|
||||
planning_engine_service: PlanningEngineService | None = None,
|
||||
leave_wish_repo: LeaveWishRepository | None = None,
|
||||
uow: UnitOfWork | None = None,
|
||||
workspace_repo: WorkspaceRepository | None = None,
|
||||
day_override_repo: DayOverrideRepository | None = None,
|
||||
) -> None:
|
||||
self._repo = repo
|
||||
self._analytics = analytics_service
|
||||
@@ -237,6 +247,59 @@ class PlanningService:
|
||||
# behavior; `app.dependencies.get_planning_service` always wires a
|
||||
# real `SQLiteUnitOfWork`.
|
||||
self._uow = uow or NoOpUnitOfWork()
|
||||
# Only needed by `_reject_illegal_free_day` (the hand-pin guard: a
|
||||
# free day — weekend/red day, per workspace `DayOverride` — can never
|
||||
# be `vacation`, and may only be `sjukpenning`/`lagsta` when the
|
||||
# workspace opted in via `take_pl_on_red_days`). Optional for the
|
||||
# same reason `_engine`/`_leave_wish_repo` are: existing direct-
|
||||
# construction call sites (scripts/seed_demo.py, older test
|
||||
# fixtures) don't wire these up and must keep today's behavior
|
||||
# unchanged — the guard is a no-op when either is unset.
|
||||
self._workspace_repo = workspace_repo
|
||||
self._day_override_repo = day_override_repo
|
||||
|
||||
async def _reject_illegal_free_day(self, workspace_id: UUID, d: date, day_type: str) -> None:
|
||||
"""Authoritative backend guard behind the calendar's hand-pin path.
|
||||
|
||||
A free day (Saturday, Sunday, or Swedish red day, subject to the
|
||||
workspace's `DayOverride`s — see `allocation_service.is_free_day`,
|
||||
the single source of truth for "is this day free") can never be
|
||||
persisted as `vacation`. It may be `sjukpenning`/`lagsta` only when
|
||||
the workspace opted in via `Workspace.take_pl_on_red_days` (default
|
||||
False). No-op for any other `day_type`, and — critically — a no-op
|
||||
entirely when either optional repo isn't wired, so callers that
|
||||
don't pass them (scripts/seed_demo.py, existing test fixtures) keep
|
||||
byte-identical behavior to before this guard existed.
|
||||
"""
|
||||
if self._workspace_repo is None or self._day_override_repo is None:
|
||||
return
|
||||
if day_type not in ("vacation", "sjukpenning", "lagsta"):
|
||||
return
|
||||
|
||||
holidays_map = get_swedish_holidays(d.year)
|
||||
overrides = {
|
||||
o.date: o.is_working_day
|
||||
for o in await self._day_override_repo.list_by_workspace(workspace_id)
|
||||
}
|
||||
if not is_free_day(d, holidays_map, overrides):
|
||||
return
|
||||
|
||||
if day_type == "vacation":
|
||||
raise ValidationError(
|
||||
"Vacation cannot be planned on a free day (weekend or red day)",
|
||||
message_key="planning.rejection.vacationOnFreeDay",
|
||||
data={"date": d.isoformat()},
|
||||
)
|
||||
|
||||
workspace = await self._workspace_repo.get_by_id(workspace_id)
|
||||
take_pl_on_red_days = workspace.take_pl_on_red_days if workspace is not None else False
|
||||
if not take_pl_on_red_days:
|
||||
raise ValidationError(
|
||||
"Parental leave cannot be planned on a free day unless the workspace "
|
||||
"setting take_pl_on_red_days is enabled",
|
||||
message_key="planning.rejection.parentalLeaveOnFreeDayDisabled",
|
||||
data={"date": d.isoformat()},
|
||||
)
|
||||
|
||||
async def add_planned_day(
|
||||
self,
|
||||
@@ -253,6 +316,11 @@ class PlanningService:
|
||||
|
||||
Validates that the day is available (dubbeldagar rules, no double-booking).
|
||||
"""
|
||||
# Validation 0: a free day (weekend/red day) can never be vacation,
|
||||
# and may only be parental leave when the workspace opted in — see
|
||||
# `_reject_illegal_free_day`.
|
||||
await self._reject_illegal_free_day(workspace_id, date, day_type)
|
||||
|
||||
# Validation 1: Check dubbeldagar quota if this is a dubbeldag
|
||||
if is_dubbeldag:
|
||||
await self._analytics.validate_dubbeldagar(child_id, date)
|
||||
@@ -343,6 +411,13 @@ class PlanningService:
|
||||
entity = await self.get(planned_leave_day_id, workspace_id)
|
||||
|
||||
if day_type is not None:
|
||||
# A free day (weekend/red day) can never be vacation, and may
|
||||
# only be parental leave when the workspace opted in — checked
|
||||
# against the row's own workspace (authoritative regardless of
|
||||
# whether a caller passed `workspace_id` for the 404 check
|
||||
# above) and its own date, since only `day_type` is changing
|
||||
# here. See `_reject_illegal_free_day`.
|
||||
await self._reject_illegal_free_day(entity.workspace_id, entity.date, day_type)
|
||||
entity.day_type = day_type
|
||||
if is_dubbeldag is not None:
|
||||
entity.is_dubbeldag = is_dubbeldag
|
||||
|
||||
Reference in New Issue
Block a user