diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 94b8e81..ec39373 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -307,9 +307,12 @@ def get_planning_service( repo: PlannedLeaveRepositoryDep, analytics_service: AnalyticsServiceDep, parental_leave_repo: ParentalLeaveRepositoryDep, + vacation_repo: VacationDaysRepositoryDep, planning_engine_service: PlanningEngineServiceDep, ) -> PlanningService: - return PlanningService(repo, analytics_service, parental_leave_repo, planning_engine_service) + return PlanningService( + repo, analytics_service, parental_leave_repo, vacation_repo, planning_engine_service + ) PlanningServiceDep = Annotated[PlanningService, Depends(get_planning_service)] diff --git a/backend/app/services/planning_service.py b/backend/app/services/planning_service.py index 94172e9..1036e5e 100644 --- a/backend/app/services/planning_service.py +++ b/backend/app/services/planning_service.py @@ -10,6 +10,7 @@ from app.config import settings from app.entities.planned_leave import PlannedLeaveDay from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository +from app.repositories.vacation_days_repository import VacationDaysRepository from app.services.allocation_service import PlanningEngineService, PlanProposal from app.services.analytics_service import AnalyticsService from app.utils.errors import DubbeldagError, NotFoundError, ValidationError @@ -136,11 +137,13 @@ class PlanningService: repo: PlannedLeaveRepository, analytics_service: AnalyticsService, parental_leave_repo: ParentalLeaveRepository, + vacation_repo: VacationDaysRepository, planning_engine_service: PlanningEngineService | None = None, ) -> None: self._repo = repo self._analytics = analytics_service self._parental_leave_repo = parental_leave_repo + self._vacation_repo = vacation_repo # Only needed by `sync_plan` (POST /api/plan/sync) — optional so # existing callers (scripts/seed_demo.py, older fixtures) that only # ever use the per-day CRUD/`apply_proposed_days` paths don't need to @@ -471,6 +474,9 @@ class PlanningService: domain_errors.extend( await self._validate_remaining_balance(pending, to_replace, delete_rows) ) + domain_errors.extend( + await self._validate_remaining_vacation_balance(pending, to_replace, delete_rows) + ) if domain_errors: return PlanApplyResult(accepted=False, collisions=collisions, rejections=domain_errors) @@ -858,3 +864,108 @@ class PlanningService: ) ) return errors + + async def _vacation_days_left(self, parent_uuid: UUID, year: int) -> int: + """Remaining vacation (semester) days for one parent in one calendar year. + + Vacation is budgeted per (parent, calendar year) — unlike the FP + tiers above, which are per (parent, child) — because a semesterlagen + entitlement doesn't care which child a day off is attributed to. + + Mirrors `AllocationInputLoader.load`'s vacation-budget derivation in + `allocation_service.py` exactly: `available = total_days + + saved_days_in - already_used`, where `total_days`/`saved_days_in` + come from the `VacationDays` row for (parent, year) if one exists. + A year with **no** row does NOT mean zero entitlement — the loader + defaults `total_days` to `rules.statutory_vacation_days_per_year` + (25) and flags the result `is_assumed=True`; this does the same + default (there is no `is_assumed` to carry here since this is a + plain remaining-days count, not a `VacationBudget`). + `PersonalData.min_vacation_days_per_year` is deliberately NOT used + here: per `ParentFacts.min_vacation_days_per_year`'s docstring and + the allocator's own carry-forward remedy, it is a semesterlagen + floor on days that must be *taken* each year (bounding what may be + saved forward), not a cap on how much may be spent — turning it into + a spending cap here would reject perfectly legal plans. + + Like `_tier_pool_left` above, "already used" counts every currently + persisted 'vacation' row for the (parent, year), pinned or not — this + intentionally does NOT mirror the pure `_Ledger`'s pinned-day + exclusion (that exclusion exists there only because the `_Ledger` + debits a pinned day's cost itself in a separate step); this check has + no such separate step, so it must count everything actually + persisted, the same "whatever is committed reduces what's left" + shape the sjukpenning/lagsta check above already uses. + """ + rules = settings.rules + row = await self._vacation_repo.get_by_parent_year(parent_uuid, year) + if row is not None: + total_available = row.total_days + row.saved_days_in + else: + total_available = rules.statutory_vacation_days_per_year + + committed = [ + d + for d in await self._repo.list(parent_personal_data_uuid=parent_uuid) + if d.day_type == "vacation" and d.date.year == year + ] + return max(total_available - len(committed), 0) + + async def _validate_remaining_vacation_balance( + self, + pending: list[ProposedDayInput], + to_replace: list[tuple[PlannedLeaveDay, ProposedDayInput]], + delete_rows: list[PlannedLeaveDay], + ) -> list[Rejection]: + """The batch must not spend more vacation days than remain for a year. + + Grouped by (parent, calendar year) rather than (parent, child, tier) + — see `_vacation_days_left`. Crediting `to_replace`/`delete_rows` + back before checking follows the exact same shape as + `_validate_remaining_balance` above, including for a period that + wraps New Year (e.g. Jullov, 20 Dec - 16 Jan): each `ProposedDayInput` + is grouped under its own `date.year`, so a band that crosses the + boundary is automatically split into two independently-checked + (parent, year) budgets — no special-casing needed here. + """ + errors: list[Rejection] = [] + year_pairs = { + (d.parent_personal_data_uuid, d.date.year) for d in pending if d.day_type == "vacation" + } + + for parent_uuid, year in year_pairs: + left = await self._vacation_days_left(parent_uuid, year) + added = sum( + 1 + for d in pending + if d.parent_personal_data_uuid == parent_uuid + and d.day_type == "vacation" + and d.date.year == year + ) + removed = sum( + 1 + for old, new in to_replace + if new.parent_personal_data_uuid == parent_uuid + and old.day_type == "vacation" + and new.date.year == year + ) + sum( + 1 + for row in delete_rows + if row.parent_personal_data_uuid == parent_uuid + and row.day_type == "vacation" + and row.date.year == year + ) + net_new = added - removed + if net_new > left: + errors.append( + Rejection( + message_key="planApply.rejection.insufficientVacationBalance", + data={ + "parent_personal_data_uuid": str(parent_uuid), + "year": year, + "needed": net_new, + "remaining": left, + }, + ) + ) + return errors diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py index 150c878..a6fb7de 100644 --- a/backend/scripts/seed_demo.py +++ b/backend/scripts/seed_demo.py @@ -137,7 +137,7 @@ def _build_services(conn: aiosqlite.Connection) -> DemoServices: day_override=DayOverrideService(day_overrides), leave_wish=LeaveWishService(leave_wishes), manual_fetch=ManualFetchService(parental_leave), - planning=PlanningService(planned_leave, analytics, parental_leave), + planning=PlanningService(planned_leave, analytics, parental_leave, vacation_repo), users=users, personal_data=personal_data, workspaces=workspaces, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0e3c091..e8291fd 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -298,8 +298,13 @@ async def planning_service( planned_leave_repo: SQLitePlannedLeaveRepository, analytics_service: AnalyticsService, parental_leave_repo: SQLiteParentalLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, planning_engine_service: PlanningEngineService, ) -> PlanningService: return PlanningService( - planned_leave_repo, analytics_service, parental_leave_repo, planning_engine_service + planned_leave_repo, + analytics_service, + parental_leave_repo, + vacation_repo, + planning_engine_service, ) diff --git a/backend/tests/unit/test_planning_service_apply.py b/backend/tests/unit/test_planning_service_apply.py index 92abfab..59542f0 100644 --- a/backend/tests/unit/test_planning_service_apply.py +++ b/backend/tests/unit/test_planning_service_apply.py @@ -597,14 +597,17 @@ class TestApplyProposedDaysDeleteKeys: planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: - """`apply_proposed_days` places no balance cap on 'vacation' today (no + """This batch nets to zero vacation days for 2026 (1 deleted, 1 added), - `_validate_remaining_balance` tier exists for it), so there is no cap - to "credit back" here the way there is for sjukpenning/lagsta. This - just confirms the general `delete_keys` write/validate machinery - handles a vacation-typed deletion correctly (deleted + new rows both - land right), since nothing else in this file exercises that day_type - through the delete path. + which fits comfortably under the statutory default entitlement (no + `VacationDays` row exists for this parent/year) -- so it exercises + the general `delete_keys` write/validate machinery for a vacation- + typed deletion, but does not by itself prove the vacation balance cap + (`_validate_remaining_vacation_balance`) is enforced or that its + `delete_keys` credit path matters. See + `test_planning_service_vacation.py::TestVacationBalanceDeleteKeysCredit` + for a tight-budget version that actually would reject without the + credit. """ ws, child, parent = ( seeded_ids.workspace_id, diff --git a/backend/tests/unit/test_planning_service_vacation.py b/backend/tests/unit/test_planning_service_vacation.py new file mode 100644 index 0000000..ebd29b8 --- /dev/null +++ b/backend/tests/unit/test_planning_service_vacation.py @@ -0,0 +1,334 @@ +"""Tests for `PlanningService`'s vacation-day balance validation. + +Closes the gap where `_validate_remaining_balance` checked `sjukpenning`/ +`lagsta` but never `vacation`, letting a caller persist unlimited vacation +days. Vacation is budgeted per (parent, calendar year) -- not per (parent, +child) like the FP tiers -- and mirrors `AllocationInputLoader.load`'s +`VacationBudget` derivation in `allocation_service.py`: + + available = total_days + saved_days_in - already_used + +with a year that has no `VacationDays` row defaulting `total_days` to +`rules.statutory_vacation_days_per_year` rather than zero ("not entered" +must never be read as "no entitlement"). +""" + +from datetime import date, datetime, timedelta +from uuid import uuid4 + +from app.config import settings +from app.entities.vacation_days import VacationDays +from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository +from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository +from app.services.planning_service import PlanningService, ProposedDayInput +from tests.conftest import SeededIds + + +def _make_vacation( + personal_data_uuid, + workspace_id, + year: int, + total_days: int, + saved_days_in: int = 0, +) -> VacationDays: + now = datetime.now() + return VacationDays( + id=uuid4(), + personal_data_uuid=personal_data_uuid, + workspace_id=workspace_id, + year=year, + total_days=total_days, + saved_days_in=saved_days_in, + created_at=now, + updated_at=now, + ) + + +class TestVacationBalanceWithEnteredEntitlement: + async def test_vacation_within_entitlement_accepted( + self, + planning_service: PlanningService, + vacation_repo: SQLiteVacationDaysRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5)) + + days = [ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 6)] + result = await planning_service.apply_proposed_days(ws, days) + + assert result.accepted is True, result.rejections + assert result.created_count == 5 + + async def test_vacation_beyond_entitlement_rejected_whole( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5)) + + days = [ + ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 7) + ] # 6 days, only 5 entitled + result = await planning_service.apply_proposed_days(ws, days) + + assert result.accepted is False + assert any( + r.message_key == "planApply.rejection.insufficientVacationBalance" + and r.data["year"] == 2026 + and r.data["parent_personal_data_uuid"] == str(parent) + and r.data["needed"] == 6 + and r.data["remaining"] == 5 + for r in result.rejections + ) + + rows = await planned_leave_repo.list_by_workspace_child( + workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30) + ) + assert rows == [], "a rejected batch must write nothing" + + +class TestVacationBalanceWithNoEnteredYear: + async def test_missing_year_defaults_to_statutory_entitlement_not_zero( + self, + planning_service: PlanningService, + seeded_ids: SeededIds, + ) -> None: + """No `VacationDays` row for 2026 at all -- must fall back to + `rules.statutory_vacation_days_per_year`, never to zero. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + statutory = settings.rules.statutory_vacation_days_per_year + + exactly_statutory = [ + ProposedDayInput(date(2026, 1, 1) + timedelta(days=i), parent, child, "vacation") + for i in range(statutory) + ] + result = await planning_service.apply_proposed_days(ws, exactly_statutory) + assert result.accepted is True, result.rejections + + async def test_missing_year_one_day_over_statutory_default_rejected( + self, + planning_service: PlanningService, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + statutory = settings.rules.statutory_vacation_days_per_year + + one_too_many = [ + ProposedDayInput(date(2026, 1, 1) + timedelta(days=i), parent, child, "vacation") + for i in range(statutory + 1) + ] + result = await planning_service.apply_proposed_days(ws, one_too_many) + + assert result.accepted is False + assert any( + r.message_key == "planApply.rejection.insufficientVacationBalance" + and r.data["year"] == 2026 + for r in result.rejections + ) + + +class TestVacationBalanceCountsSavedDaysIn: + """5 this year's entitlement + 3 carried forward from prior years = 8 + + available. Each test starts from a fresh (unspent) budget -- unlike + `TestVacationBalanceWithEnteredEntitlement` above, these two are kept + independent (not one accept-then-reject sequence in a single test) + specifically so the second assertion's `remaining` reflects the + *original* 8-day budget, not 8 minus whatever a prior call in the same + test already persisted. + """ + + async def test_exactly_available_including_saved_days_in_accepted( + self, + planning_service: PlanningService, + vacation_repo: SQLiteVacationDaysRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5, saved_days_in=3)) + + exactly_available = [ + ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 9) + ] + result = await planning_service.apply_proposed_days(ws, exactly_available) + assert result.accepted is True, result.rejections + assert result.created_count == 8 + + async def test_one_more_than_saved_days_in_allows_rejected( + self, + planning_service: PlanningService, + vacation_repo: SQLiteVacationDaysRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5, saved_days_in=3)) + + one_more = [ + ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 10) + ] + result_over = await planning_service.apply_proposed_days(ws, one_more) + assert result_over.accepted is False + assert any( + r.message_key == "planApply.rejection.insufficientVacationBalance" + and r.data["needed"] == 9 + and r.data["remaining"] == 8 + for r in result_over.rejections + ) + + +class TestVacationBalanceDeleteKeysCredit: + async def test_delete_and_readd_same_count_same_year_accepted( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + seeded_ids: SeededIds, + ) -> None: + """A tight 1-day budget already fully spent -- adding one more + vacation day would be rejected outright, but replacing (delete + + re-add, same count, same year) must be accepted because the deletion + is credited back before the new demand is checked. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=1)) + old = await planning_service.add_planned_day( + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=date(2026, 6, 1), + day_type="vacation", + ) + + # Sanity: with the budget already fully spent, a bare addition (no + # delete) of one more vacation day in the same year is rejected. + bare_addition = [ProposedDayInput(date(2026, 6, 2), parent, child, "vacation")] + blocked = await planning_service.apply_proposed_days(ws, bare_addition) + assert blocked.accepted is False + assert any( + r.message_key == "planApply.rejection.insufficientVacationBalance" + for r in blocked.rejections + ) + + new_days = [ProposedDayInput(date(2026, 6, 2), parent, child, "vacation")] + delete_keys = {(child, parent, date(2026, 6, 1))} + + result = await planning_service.apply_proposed_days(ws, new_days, delete_keys=delete_keys) + + assert result.accepted is True, result.rejections + assert result.deleted_count == 1 + assert result.created_count == 1 + + rows = await planned_leave_repo.list_by_workspace_child( + workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30) + ) + assert {r.date for r in rows} == {date(2026, 6, 2)} + assert old.id not in {r.id for r in rows} + + +class TestVacationBalanceNewYearWrap: + async def test_wrapping_period_charges_each_year_independently( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + seeded_ids: SeededIds, + ) -> None: + """A Jullov-shaped period (20 Dec - 16 Jan) spans two vacation-year + budgets. 2026 has ample room (12 days needed, 15 entitled); 2027 is + deliberately too tight (16 days needed, only 10 entitled) -- the + whole batch must be rejected, and the rejection must name 2027, not + 2026 or a summed total across both years. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=15)) + await vacation_repo.create(_make_vacation(parent, ws, 2027, total_days=10)) + + dec_days = [ + ProposedDayInput(date(2026, 12, d), parent, child, "vacation") for d in range(20, 32) + ] # 12 days + jan_days = [ + ProposedDayInput(date(2027, 1, d), parent, child, "vacation") for d in range(1, 17) + ] # 16 days + batch = dec_days + jan_days + + result = await planning_service.apply_proposed_days(ws, batch) + + assert result.accepted is False + rejections_by_year = { + r.data["year"]: r + for r in result.rejections + if r.message_key == "planApply.rejection.insufficientVacationBalance" + } + assert 2027 in rejections_by_year + assert rejections_by_year[2027].data["needed"] == 16 + assert rejections_by_year[2027].data["remaining"] == 10 + assert 2026 not in rejections_by_year, "2026 has room and must not be rejected" + + rows = await planned_leave_repo.list_by_workspace_child( + workspace_id=ws, child_id=child, from_date=date(2026, 12, 1), to_date=date(2027, 1, 31) + ) + assert rows == [], "a rejected batch must write nothing, including the in-budget year" + + async def test_wrapping_period_accepted_when_both_years_have_room( + self, + planning_service: PlanningService, + vacation_repo: SQLiteVacationDaysRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=15)) + await vacation_repo.create(_make_vacation(parent, ws, 2027, total_days=20)) + + dec_days = [ + ProposedDayInput(date(2026, 12, d), parent, child, "vacation") for d in range(20, 32) + ] + jan_days = [ + ProposedDayInput(date(2027, 1, d), parent, child, "vacation") for d in range(1, 17) + ] + batch = dec_days + jan_days + + result = await planning_service.apply_proposed_days(ws, batch) + + assert result.accepted is True, result.rejections + assert result.created_count == 28