Check vacation days against the entitlement before persisting them

Applied batches were validated against the remaining föräldrapenning tiers
but never against semester. That held only while the allocator was the sole
writer, since its ledger spends vacation against a per-year budget. The
calendar is about to let a user force any day to semester and pin it, which
writes straight past that ledger, so nothing would have stopped forty pinned
vacation days against a twenty-five day entitlement.

The check mirrors the allocator's own derivation rather than restating it:
available days are total plus days saved in, and a year with no entered row
falls back to the statutory entitlement instead of reading as zero. The
semesterlagen floor stays out of it, being a bound on what may be carried
forward rather than a cap on what may be spent. Vacation is budgeted per
calendar year, so a period crossing New Year is charged to both.
This commit is contained in:
2026-09-17 18:22:55 +02:00
parent c1aa4ae0b0
commit d06c8c8449
6 changed files with 466 additions and 10 deletions
+111
View File
@@ -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