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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user