Give the analytics overview a per-parent, per-year balance series

Each parent projection now carries one entry per calendar year from
birth to the fourth birthday: vacation earned, carried in, planned,
left, carried out and expiring under Semesterlagen's 20-day floor and
5-year carry-over, plus parental-leave days used and left at year end
with the age-4 clamp applied in the birthday year. The overview sums
them into per-year totals. Frontend types, mapper and fixtures follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-19 19:02:02 +02:00
parent dbef997da4
commit 4b802ba0be
14 changed files with 1365 additions and 37 deletions
+6
View File
@@ -62,6 +62,12 @@ class LeaveRules(BaseModel):
# allocator actually enforces (PLANNING_ENGINE_SPEC.md §9.2).
min_vacation_days_per_year: int = 20
# Semesterlagen: a vacation day saved above the floor (see
# `min_vacation_days_per_year`) may be carried forward for this many
# years before it expires. Used by `AnalyticsService`'s per-year vacation
# carry-forward bank (§ per-parent-per-year balance series).
vacation_carry_forward_years: int = 5
@property
def total_days_per_child(self) -> int:
return self.sjukpenning_days + self.lagsta_niva_days
+4 -1
View File
@@ -354,8 +354,11 @@ def get_analytics_service(
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
) -> AnalyticsService:
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
return AnalyticsService(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)]
+82 -1
View File
@@ -5,7 +5,84 @@ from uuid import UUID
from pydantic import BaseModel
from app.services.analytics_service import Age4Projection, WorkspaceOverview
from app.services.analytics_service import (
Age4Projection,
ParentalLeaveYearBalance,
ParentYearBalance,
VacationYearBalance,
WorkspaceOverview,
YearTotals,
)
class VacationYearBalanceResponse(BaseModel):
year: int
earned: int
is_assumed: bool
carried_in: int
planned_use: int
left: int
carried_out: int
expiring: int
@classmethod
def from_entity(cls, v: VacationYearBalance) -> "VacationYearBalanceResponse":
return cls(
year=v.year,
earned=v.earned,
is_assumed=v.is_assumed,
carried_in=v.carried_in,
planned_use=v.planned_use,
left=v.left,
carried_out=v.carried_out,
expiring=v.expiring,
)
class ParentalLeaveYearBalanceResponse(BaseModel):
year: int
sjukpenning_used: int
lagsta_used: int
sjukpenning_left: int
lagsta_left: int
@classmethod
def from_entity(cls, p: ParentalLeaveYearBalance) -> "ParentalLeaveYearBalanceResponse":
return cls(
year=p.year,
sjukpenning_used=p.sjukpenning_used,
lagsta_used=p.lagsta_used,
sjukpenning_left=p.sjukpenning_left,
lagsta_left=p.lagsta_left,
)
class ParentYearBalanceResponse(BaseModel):
personal_data_uuid: UUID
child_id: UUID
year: int
vacation: VacationYearBalanceResponse
parental_leave: ParentalLeaveYearBalanceResponse
@classmethod
def from_entity(cls, p: ParentYearBalance) -> "ParentYearBalanceResponse":
return cls(
personal_data_uuid=p.personal_data_uuid,
child_id=p.child_id,
year=p.year,
vacation=VacationYearBalanceResponse.from_entity(p.vacation),
parental_leave=ParentalLeaveYearBalanceResponse.from_entity(p.parental_leave),
)
class YearTotalsResponse(BaseModel):
year: int
pl_left: int
vacation_left: int
@classmethod
def from_entity(cls, y: YearTotals) -> "YearTotalsResponse":
return cls(year=y.year, pl_left=y.pl_left, vacation_left=y.vacation_left)
class Age4ProjectionResponse(BaseModel):
@@ -20,6 +97,7 @@ class Age4ProjectionResponse(BaseModel):
savable_after_b4: int
expiring_at_b4: int
savable_cap_at_b4: int
year_balances: list[ParentYearBalanceResponse]
@classmethod
def from_entity(cls, p: Age4Projection) -> "Age4ProjectionResponse":
@@ -35,6 +113,7 @@ class Age4ProjectionResponse(BaseModel):
savable_after_b4=p.savable_after_b4,
expiring_at_b4=p.expiring_at_b4,
savable_cap_at_b4=p.savable_cap_at_b4,
year_balances=[ParentYearBalanceResponse.from_entity(yb) for yb in p.year_balances],
)
@@ -45,6 +124,7 @@ class WorkspaceOverviewResponse(BaseModel):
combined_days_used: int
combined_days_left: int
combined_expiring_at_b4: int
year_totals: list[YearTotalsResponse]
@classmethod
def from_entity(cls, overview: WorkspaceOverview) -> "WorkspaceOverviewResponse":
@@ -57,4 +137,5 @@ class WorkspaceOverviewResponse(BaseModel):
combined_days_used=overview.combined_days_used,
combined_days_left=overview.combined_days_left,
combined_expiring_at_b4=overview.combined_expiring_at_b4,
year_totals=[YearTotalsResponse.from_entity(y) for y in overview.year_totals],
)
+17 -4
View File
@@ -9,6 +9,7 @@ from app.dependencies import (
ParentalLeaveRepositoryDep,
PersonalDataRepositoryDep,
PlannedLeaveRepositoryDep,
VacationDaysRepositoryDep,
WorkspaceIdDep,
WorkspaceRepositoryDep,
WorkspaceScopeDep,
@@ -26,8 +27,11 @@ def _make_service(
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
) -> AnalyticsService:
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
return AnalyticsService(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
@router.get("/{workspace_id}/overview", response_model=WorkspaceOverviewResponse)
@@ -39,8 +43,11 @@ async def get_overview(
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
) -> WorkspaceOverviewResponse:
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
try:
overview = await service.get_overview(workspace_id, child_id)
except NotFoundError as exc:
@@ -57,8 +64,11 @@ async def get_projection(
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
) -> WorkspaceOverviewResponse:
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
try:
overview = await service.get_overview(workspace_id, child_id)
except NotFoundError as exc:
@@ -77,11 +87,14 @@ async def get_age4_projection(
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> Age4ProjectionResponse:
await scope.check_member(personal_data_uuid)
await scope.check_child(child_id)
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
try:
proj = await service.get_days_left_at_age_4(
personal_data_uuid, child_id, workspace_id=scope.workspace_id
+331 -1
View File
@@ -18,17 +18,22 @@ dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic.
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import date
from uuid import UUID
from dateutil.relativedelta import relativedelta
from app.config import settings
from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch
from app.entities.planned_leave import PlannedLeaveDay
from app.entities.vacation_days import VacationDays
from app.repositories.child_repository import ChildRepository
from app.repositories.parental_leave_repository import ParentalLeaveRepository
from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.repositories.vacation_days_repository import VacationDaysRepository
from app.repositories.workspace_repository import WorkspaceRepository
from app.utils.errors import DubbeldagError, NotFoundError
from app.utils.tip_predicates import (
@@ -38,6 +43,85 @@ from app.utils.tip_predicates import (
)
@dataclass
class VacationYearBalance:
"""One parent's vacation (semester) balance for a single calendar year.
Part of the per-parent, per-year balance series (`ParentYearBalance`) —
see that dataclass's docstring for the carry-forward algorithm.
"""
year: int
earned: int # entitlement for this year: the `vacation_days` row's total_days, or the statutory default
is_assumed: bool # True when no `vacation_days` row exists for this (parent, year)
carried_in: int # days carried in from prior years, still valid
planned_use: int # committed 'vacation' planned_leave_day rows in this year (see PlanningService._vacation_days_left)
left: int # max(earned + carried_in - planned_use, 0)
carried_out: int # days saved forward into next year's carried_in
expiring: int # days that hit the 5-year (rules.vacation_carry_forward_years) cap this year — LOST if not used
@dataclass
class ParentalLeaveYearBalance:
"""One parent's föräldrapenning (FP) balance for a single calendar year,
for one child — see `ParentYearBalance`."""
year: int
sjukpenning_used: int # FP sjukpenning-tier rows dated in this year
lagsta_used: int # FP lägstanivå-tier rows dated in this year
sjukpenning_left: int # running balance at this year's end
lagsta_left: int # running balance at this year's end
@dataclass
class ParentYearBalance:
"""One (parent, child, calendar year) slice of the balance series shown
on the analytics overview — vacation and FP side by side.
**Vacation carry-forward algorithm** (Semesterlagen): a parent must take
at least `PersonalData.min_vacation_days_per_year` (the "floor") each
year; only days earned *above* the floor may be saved, and each saved day
expires `LeaveRules.vacation_carry_forward_years` (5) years after the
year it was earned. Modelled as a FIFO bank of `(year_earned, count)`
entries:
- Each year, `planned_use` is drawn from the bank oldest-entry-first,
then from the year's own entitlement (which is never itself banked).
- At year end, `carried_out = min(left, max(earned - floor, 0) +
old_carry_forward)` joins the bank as a new entry dated this year —
`old_carry_forward` (the surviving pre-existing bank balance) keeps its
original earn-year for its own future expiry, it is never re-dated.
- A bank entry in its final valid year (earned exactly
`vacation_carry_forward_years` years ago) that still has an unconsumed
balance drops out instead of carrying forward, and is reported as
`expiring` for that year.
`saved_days_in` on a `vacation_days` row is normally only used to *seed*
the bank for the first year of the series (year-earned = year − 1, since
we have no earlier history to derive it from). For any LATER year in the
series, a non-zero `saved_days_in` on that year's row is instead treated
as a Settings-UI override: it replaces the computed `carried_in` (and
resets the bank to a single entry dated year − 1) rather than being
added to it — the user is telling us the true figure directly. A zero
(or absent) `saved_days_in` on a later year never overrides anything.
"""
personal_data_uuid: UUID
child_id: UUID
year: int
vacation: VacationYearBalance
parental_leave: ParentalLeaveYearBalance
@dataclass
class YearTotals:
"""Combined (all parents) balance for one calendar year, on the overview."""
year: int
pl_left: int
vacation_left: int
@dataclass
class Age4Projection:
"""Per-parent projection of days remaining at the child's 4th birthday."""
@@ -57,6 +141,9 @@ class Age4Projection:
# `resolve_age4_savable_cap`. Surfaced so the frontend can render "X kvar
# / Y sparbara" without re-deriving the rule itself.
savable_cap_at_b4: int
# Per-calendar-year vacation + FP balance series, birth year through the
# year of the 4th birthday inclusive. See `ParentYearBalance`.
year_balances: list[ParentYearBalance] = field(default_factory=list)
@dataclass
@@ -67,6 +154,8 @@ class WorkspaceOverview:
combined_days_used: int
combined_days_left: int
combined_expiring_at_b4: int
# Combined (all parents) per-year totals — see `YearTotals`.
year_totals: list[YearTotals] = field(default_factory=list)
class AnalyticsService:
@@ -77,12 +166,14 @@ class AnalyticsService:
child_repo: ChildRepository,
personal_data_repo: PersonalDataRepository,
workspace_repo: WorkspaceRepository,
vacation_days_repo: VacationDaysRepository,
) -> None:
self._leave = parental_leave_repo
self._planned = planned_leave_repo
self._children = child_repo
self._parents = personal_data_repo
self._workspace_repo = workspace_repo
self._vacation = vacation_days_repo
async def get_parent_count(self, workspace_id: UUID) -> int:
"""Number of (non-deleted) parents on a workspace.
@@ -134,6 +225,7 @@ class AnalyticsService:
b4 = child.birth_date + relativedelta(years=4)
latest = await self._leave.get_latest_by_parent(personal_data_uuid, child_id)
default_share: tuple[int, int] | None = None
if latest is None:
# No FK data yet — assume full entitlement (0 used) for this
# parent's share, via the same shared helper `AllocationInputLoader.
@@ -144,6 +236,7 @@ class AnalyticsService:
parent_count = await self.get_parent_count(child.workspace_id)
sjuk_share, lagsta_share = default_tier_pool_share(rules, parent_count)
total_days = sjuk_share + lagsta_share
default_share = (sjuk_share, lagsta_share)
else:
days_used = latest.days_used
total_days = latest.total_days
@@ -180,6 +273,10 @@ class AnalyticsService:
)
savable = min(projected_left, savable_cap)
year_balances = await self._compute_year_balances(
personal_data_uuid, child, b4, savable_cap, latest, default_share
)
return Age4Projection(
personal_data_uuid=personal_data_uuid,
child_id=child_id,
@@ -192,8 +289,228 @@ class AnalyticsService:
savable_after_b4=savable,
expiring_at_b4=expiring,
savable_cap_at_b4=savable_cap,
year_balances=year_balances,
)
async def _compute_year_balances(
self,
personal_data_uuid: UUID,
child: Child,
fourth_birthday: date,
savable_cap: int,
latest_fetch: ParentalLeaveFetch | None,
default_share: tuple[int, int] | None,
) -> list[ParentYearBalance]:
"""Assemble the per-calendar-year vacation + FP balance series for
one (parent, child) pair — see `ParentYearBalance`'s docstring for
the vacation carry-forward algorithm.
`years` runs from the child's birth year through the year of the
4th birthday inclusive.
"""
years = list(range(child.birth_date.year, fourth_birthday.year + 1))
personal_data = await self._parents.get_by_id(personal_data_uuid)
floor = (
personal_data.min_vacation_days_per_year
if personal_data is not None
else settings.rules.min_vacation_days_per_year
)
vacation_rows = await self._vacation.list(personal_data_uuid=personal_data_uuid)
vacation_by_year = {v.year: v for v in vacation_rows}
# Vacation is budgeted per (parent, calendar year), not per child —
# see `PlanningService._vacation_days_left`'s docstring. `planned_use`
# below deliberately counts every persisted 'vacation' row for this
# parent across ALL of their children in that year, matching that
# method exactly, so this row's `left` never disagrees with the Plan
# tab's own vacation-balance check.
all_parent_days = await self._planned.list(parent_personal_data_uuid=personal_data_uuid)
vacation_use_by_year: dict[int, int] = {}
for d in all_parent_days:
if d.day_type == "vacation":
vacation_use_by_year[d.date.year] = vacation_use_by_year.get(d.date.year, 0) + 1
vacation_balances = self._vacation_year_series(
years, vacation_by_year, vacation_use_by_year, floor
)
fp_rows = [
d
for d in all_parent_days
if d.child_id == child.id and d.day_type in ("sjukpenning", "lagsta")
]
pl_balances = self._pl_year_series(
years,
fp_rows,
child.birth_date,
fourth_birthday,
savable_cap,
latest_fetch,
default_share,
)
return [
ParentYearBalance(
personal_data_uuid=personal_data_uuid,
child_id=child.id,
year=year,
vacation=vacation_balances[i],
parental_leave=pl_balances[i],
)
for i, year in enumerate(years)
]
@staticmethod
def _vacation_year_series(
years: list[int],
vacation_by_year: dict[int, VacationDays],
use_by_year: dict[int, int],
floor: int,
) -> list[VacationYearBalance]:
"""FIFO carry-forward bank simulation — see `ParentYearBalance`'s
docstring for the full algorithm this implements."""
carry_years = settings.rules.vacation_carry_forward_years
default_earned = settings.rules.statutory_vacation_days_per_year
# (year_earned, remaining_count) entries, oldest first.
bank: list[list[int]] = []
result: list[VacationYearBalance] = []
for i, year in enumerate(years):
row = vacation_by_year.get(year)
earned = row.total_days if row is not None else default_earned
is_assumed = row is None
if i == 0:
# Seed the bank for the first year in the series — there is
# no earlier history to derive a carry-in from ourselves, so
# this is purely the user-entered figure (Settings UI),
# attributed to year - 1 for its own future expiry.
seed = row.saved_days_in if row is not None else 0
bank = [[year - 1, seed]] if seed > 0 else []
carried_in = seed
elif row is not None and row.saved_days_in != 0:
# Later-year override: a non-zero saved_days_in on THIS
# year's row supersedes the computed carry-in — see
# `ParentYearBalance`'s docstring.
bank = [[year - 1, row.saved_days_in]] if row.saved_days_in > 0 else []
carried_in = row.saved_days_in
else:
carried_in = sum(count for _, count in bank)
planned_use = use_by_year.get(year, 0)
left = max(earned + carried_in - planned_use, 0)
# FIFO consumption: oldest bank entries first, then this year's
# own entitlement (never itself banked).
remaining_to_consume = planned_use
for entry in bank:
if remaining_to_consume <= 0:
break
take = min(entry[1], remaining_to_consume)
entry[1] -= take
remaining_to_consume -= take
bank = [e for e in bank if e[1] > 0]
# Entries still valid to carry into next year vs. ones in their
# final valid year (expire now if any balance survived).
old_carry_forward = sum(c for ye, c in bank if ye >= year - (carry_years - 1))
expiring = sum(c for ye, c in bank if ye == year - carry_years)
bank = [e for e in bank if e[0] >= year - (carry_years - 1)]
current_year_saveable = max(earned - floor, 0)
carried_out = min(left, current_year_saveable + old_carry_forward)
new_entry_amount = max(carried_out - old_carry_forward, 0)
if new_entry_amount > 0:
bank.append([year, new_entry_amount])
result.append(
VacationYearBalance(
year=year,
earned=earned,
is_assumed=is_assumed,
carried_in=carried_in,
planned_use=planned_use,
left=left,
carried_out=carried_out,
expiring=expiring,
)
)
return result
@staticmethod
def _pl_year_series(
years: list[int],
fp_rows: list[PlannedLeaveDay],
birth_date: date,
fourth_birthday: date,
savable_cap: int,
latest_fetch: ParentalLeaveFetch | None,
default_share: tuple[int, int] | None,
) -> list[ParentalLeaveYearBalance]:
"""Running FP (sjukpenning/lagsta) balance at each year's end.
Starts from the latest snapshot's tier lefts (or, with no snapshot,
this parent's default share — see `default_tier_pool_share`), then
subtracts planned FP rows dated after the snapshot (or from the
child's birth when there is none) cumulatively, year by year. The
age-4 clamp (`resolve_age4_savable_cap`) is applied once, in the year
of the 4th birthday, cutting lagsta first — mirrors `allocation_
service._Ledger._maybe_clamp_age4` exactly.
"""
if latest_fetch is not None:
sjuk_left = latest_fetch.tier_data.sjukpenning_left
lagsta_left = latest_fetch.tier_data.lagsta_niva_left
cutoff = latest_fetch.fetched_at.date()
has_snapshot = True
else:
assert default_share is not None
sjuk_left, lagsta_left = default_share
cutoff = birth_date
has_snapshot = False
used_by_year: dict[int, dict[str, int]] = {}
deduct_by_year: dict[int, dict[str, int]] = {}
for d in fp_rows:
y = d.date.year
bucket = used_by_year.setdefault(y, {"sjukpenning": 0, "lagsta": 0})
bucket[d.day_type] += 1
is_post_snapshot = d.date > cutoff if has_snapshot else d.date >= cutoff
if is_post_snapshot:
deduct = deduct_by_year.setdefault(y, {"sjukpenning": 0, "lagsta": 0})
deduct[d.day_type] += 1
result: list[ParentalLeaveYearBalance] = []
for year in years:
used = used_by_year.get(year, {"sjukpenning": 0, "lagsta": 0})
deduct = deduct_by_year.get(year, {"sjukpenning": 0, "lagsta": 0})
sjuk_left = max(sjuk_left - deduct["sjukpenning"], 0)
lagsta_left = max(lagsta_left - deduct["lagsta"], 0)
if year == fourth_birthday.year:
remaining = sjuk_left + lagsta_left
capped = min(remaining, savable_cap)
excess = remaining - capped
if excess > 0:
cut_lagsta = min(excess, lagsta_left)
lagsta_left -= cut_lagsta
excess -= cut_lagsta
sjuk_left -= excess
result.append(
ParentalLeaveYearBalance(
year=year,
sjukpenning_used=used["sjukpenning"],
lagsta_used=used["lagsta"],
sjukpenning_left=sjuk_left,
lagsta_left=lagsta_left,
)
)
return result
async def get_overview(self, workspace_id: UUID, child_id: UUID) -> WorkspaceOverview:
"""Compute projections for all parents in a workspace for one child."""
members = await self._parents.list_by_workspace(workspace_id)
@@ -209,6 +526,18 @@ class AnalyticsService:
combined_left = sum(p.projected_left_at_b4 for p in projections)
combined_expiring = sum(p.expiring_at_b4 for p in projections)
# Combined (all parents) per-year totals — see `YearTotals`.
totals_by_year: dict[int, list[int]] = {} # year -> [pl_left, vacation_left]
for proj in projections:
for yb in proj.year_balances:
acc = totals_by_year.setdefault(yb.year, [0, 0])
acc[0] += yb.parental_leave.sjukpenning_left + yb.parental_leave.lagsta_left
acc[1] += yb.vacation.left
year_totals = [
YearTotals(year=year, pl_left=acc[0], vacation_left=acc[1])
for year, acc in sorted(totals_by_year.items())
]
return WorkspaceOverview(
workspace_id=workspace_id,
child_id=child_id,
@@ -216,6 +545,7 @@ class AnalyticsService:
combined_days_used=combined_used,
combined_days_left=combined_left,
combined_expiring_at_b4=combined_expiring,
year_totals=year_totals,
)
async def validate_dubbeldagar(self, child_id: UUID, new_date: date) -> None: