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