Files
F-r-ldraledigplannerare/backend/app/services/analytics_service.py
T
jacobh2 1a02bdcdbf Let vacation years go back to the child's birth year
Settings only offered vacation-day rows from the current year forward,
while analytics ran its balance series from the child's birth year. Every
year in the gap had no vacation_days row, so the series assumed a full
statutory 25 earned with no use and banked earned - floor of it forward —
phantom saved days the user had no row to correct.

Settings now offers editable rows back to the birth year, and a past year
with no row assumes only the floor was earned, so it banks nothing. A past
year the user did enter is untouched by that rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:49:56 +02:00

644 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Analytics service: days-left-at-age-4 and planned-vs-actual reconciliation.
The headline calculation (§6.5 ARCHITECTURE.md):
projected_used_by_B4 = days_used + planned_days(date < B4)
projected_left_at_B4 = total_days - projected_used_by_B4
savable_after_B4 = min(projected_left_at_B4, CAP) # age-4 ceiling
expiring_at_B4 = max(projected_left_at_B4 - CAP, 0) # LOST days
Where `CAP` is 96 (per child) for a genuine single parent, or 48 (per
parent, non-poolable) as soon as the child has two parents. See `app.utils.
tip_predicates.resolve_age4_savable_cap`, the single function that decides
which applies.
All rule constants come from config.LeaveRules — no magic numbers.
dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic.
"""
from __future__ import annotations
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 (
days_left_at_age_4_projection,
default_tier_pool_share,
resolve_age4_savable_cap,
)
@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
# EVERY day lost at this year's end, from both causes combined — the sum
# of `expiring_age` and `expiring_over_cap` below. Never a subset: a UI
# showing only `expiring` must not silently under-report a forfeiture.
expiring: int
# subset of `expiring`: bank entries that hit the 5-year
# (rules.vacation_carry_forward_years) age cap this year.
expiring_age: int
# subset of `expiring`: days above (earned - floor) that couldn't be
# banked — includes an unused floor itself when usage is low.
expiring_over_cap: int
@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_age` for that year.
- Separately, whatever of `left` cannot be absorbed by `carried_out`
(days above `earned - floor` with nowhere to bank — including an
unused floor itself, when usage is low) is `expiring_over_cap` for
that year.
- `expiring` is `expiring_age + expiring_over_cap`: EVERY vacation day
lost at this year's end, however it was lost. Earlier versions of this
field counted only the age-cap portion, so a family could lose days
purely to the over-cap cause (e.g. earned 30, floor 20, used 15 →
5 days over-cap lost) while the dashboard showed "Expiring 0" — that
silent gap is why the breakdown exists now.
- `earned` for a year with no `vacation_days` row depends on whether the
year is in the past relative to `current_year` (the caller's "now").
`year >= current_year` assumes the full statutory entitlement, same as
always — unconfirmed (`is_assumed=True`), but a forward-looking guess.
`year < current_year` instead assumes only the floor was earned, so
`current_year_saveable` is `max(floor - floor, 0) = 0` and
`carried_out = min(left, old_carry_forward)` — a past year with no row
never phantom-banks days the user never told us they saved. Settings
offers editable rows back to the child's birth year precisely so a
genuine past figure can replace this assumption. A row the user DID
enter for a past year is untouched by this rule: `earned` is always
that row's `total_days`, regardless of 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."""
personal_data_uuid: UUID
child_id: UUID
birth_date: date
fourth_birthday: date
total_days: int
days_used: int # from latest FK snapshot
planned_days_before_b4: int # from calendar
projected_left_at_b4: int
savable_after_b4: int # min(projected_left, savable_cap_at_b4)
expiring_at_b4: int # max(projected_left - savable_cap_at_b4, 0) — LOST if not used
# The cap actually applied — 96 (per child, single parent) or 48 (per
# parent, non-poolable, as soon as the child has 2+ parents). See
# `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
class WorkspaceOverview:
workspace_id: UUID
child_id: UUID
parent_projections: list[Age4Projection]
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:
def __init__(
self,
parental_leave_repo: ParentalLeaveRepository,
planned_leave_repo: PlannedLeaveRepository,
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.
Shared read so callers that need a parent count (the age-4 cap here,
`PlanningService._tier_pool_left`'s default-tier-share lookup) never
query membership differently.
"""
members = await self._parents.list_by_workspace(workspace_id)
return len(members)
async def get_parent_count_for_child(self, child_id: UUID) -> int:
"""Number of parents on the workspace that owns this child.
Lets a caller that only has a `child_id` (no `workspace_id` in
scope) still resolve a parent count — see `PlanningService.
_tier_pool_left`, which only ever receives `(parent_uuid, child_id)`.
"""
child = await self._children.get_by_id(child_id)
if child is None:
raise NotFoundError(f"Child {child_id} not found")
return await self.get_parent_count(child.workspace_id)
async def _resolve_savable_cap(self, workspace_id: UUID) -> int:
"""The single call site AnalyticsService uses for the age-4 cap —
wraps `resolve_age4_savable_cap` with the DB read it needs (member
count) so callers never resolve the cap any other way. The cap is a
fact about how many parents the child has, not a workspace display
preference — see `resolve_age4_savable_cap`'s docstring.
"""
return resolve_age4_savable_cap(await self.get_parent_count(workspace_id))
async def get_days_left_at_age_4(
self, personal_data_uuid: UUID, child_id: UUID, workspace_id: UUID | None = None
) -> Age4Projection:
"""Compute the headline projection for a single parent+child pair.
`workspace_id`, when given, must match the child's own — a foreign
workspace's child 404s exactly like a nonexistent one.
"""
rules = settings.rules
child = await self._children.get_by_id(child_id)
if child is None or (workspace_id is not None and child.workspace_id != workspace_id):
raise NotFoundError(f"Child {child_id} not found")
# Compute the 4th birthday with leap-year-safe arithmetic.
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.
# load`/`PlanningService._tier_pool_left` use, so a sole parent
# (their whole-pool share) and a partnered one (half) never
# disagree with the Plan tab's own no-snapshot default (Bug A).
days_used = 0
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
# Count föräldrapenning planned days that fall before the 4th birthday.
# Vacation days do NOT count against the FP pool.
today = date.today()
sjukpenning_before_b4 = await self._planned.count_by_parent_type(
personal_data_uuid,
child_id,
"sjukpenning",
from_date=today,
to_date=b4,
)
lagsta_before_b4 = await self._planned.count_by_parent_type(
personal_data_uuid,
child_id,
"lagsta",
from_date=today,
to_date=b4,
)
planned_fp_before_b4 = sjukpenning_before_b4 + lagsta_before_b4
savable_cap = await self._resolve_savable_cap(child.workspace_id)
# Shared with the plan-proposal side (`allocation_service._Ledger.
# build_age4_projections`) so the two surfaces can never re-derive
# this arithmetic differently — see `days_left_at_age_4_projection`.
projected_left, expiring = days_left_at_age_4_projection(
days_used=days_used,
planned_fp_before_b4=planned_fp_before_b4,
total_days=total_days,
max_savable=savable_cap,
)
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,
birth_date=child.birth_date,
fourth_birthday=b4,
total_days=total_days,
days_used=days_used,
planned_days_before_b4=planned_fp_before_b4,
projected_left_at_b4=projected_left,
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, date.today().year
)
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,
current_year: int,
) -> list[VacationYearBalance]:
"""FIFO carry-forward bank simulation — see `ParentYearBalance`'s
docstring for the full algorithm this implements, including the
past-vs-current-year `earned` rule (`current_year`) applied below for
a year with no entered `vacation_days` row."""
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)
if row is not None:
earned = row.total_days
elif year >= current_year:
earned = default_earned
else:
# Past year, no entered row: assume only the floor was
# earned/used, not the full statutory default — see
# `ParentYearBalance`'s docstring. Keeps a phantom past year
# from banking `default_earned - floor` days the user never
# actually saved.
earned = floor
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_age_raw = 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])
# `left` minus what actually got banked is EVERY day lost this
# year — from both causes. `expiring_age_raw` (bank entries that
# just hit the age cap) is a subset of that total by construction
# (those days are inside `left` and excluded from `carried_out`
# via `old_carry_forward`), but clamp it defensively so the two
# portions can never sum to more than `total_lost` in an edge
# case (e.g. `left` itself clamped to 0 by the `max(..., 0)`
# above, when planned_use outstrips earned + carried_in).
total_lost = max(left - carried_out, 0)
expiring_age = min(expiring_age_raw, total_lost)
expiring_over_cap = total_lost - expiring_age
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=total_lost,
expiring_age=expiring_age,
expiring_over_cap=expiring_over_cap,
)
)
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)
if not members:
raise NotFoundError(f"No parents found in workspace {workspace_id}")
projections = []
for pd in members:
proj = await self.get_days_left_at_age_4(pd.uuid, child_id, workspace_id=workspace_id)
projections.append(proj)
combined_used = sum(p.days_used + p.planned_days_before_b4 for p in projections)
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,
parent_projections=projections,
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:
"""Raise DubbeldagError if adding a dubbeldagar day would break rules.
Called from the planning service before persisting a new day.
"""
child = await self._children.get_by_id(child_id)
if child is None:
raise NotFoundError(f"Child {child_id} not found")
# Window: child's birth_date ... birth_date + 15 months
window_end = child.birth_date + relativedelta(
months=settings.rules.dubbeldagar_months_limit
)
if new_date > window_end:
raise DubbeldagError(
f"Dubbeldagar only allowed within the child's first "
f"{settings.rules.dubbeldagar_months_limit} months "
f"(deadline {window_end})",
message_key="planApply.rejection.dubbeldagWindowExceeded",
data={
"months_limit": settings.rules.dubbeldagar_months_limit,
"deadline": window_end.isoformat(),
},
)
# Quota: count already-planned dubbeldagar from child's birth_date onward.
already_planned = await self._planned.count_dubbeldagar(
child_id, from_date=child.birth_date
)
if already_planned >= settings.rules.max_dubbeldagar:
raise DubbeldagError(
f"Dubbeldagar quota ({settings.rules.max_dubbeldagar} days) already reached",
message_key="planApply.rejection.dubbeldagQuotaReached",
data={"max_dubbeldagar": settings.rules.max_dubbeldagar},
)