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
+7 -1
View File
@@ -337,9 +337,15 @@ async def analytics_service(
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> AnalyticsService:
return AnalyticsService(
parental_leave_repo, planned_leave_repo, child_repo, personal_data_repo, workspace_repo
parental_leave_repo,
planned_leave_repo,
child_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
@@ -0,0 +1,94 @@
"""API-level tests for `GET /api/analytics/{workspace_id}/overview` and the
per-parent age4 endpoint — specifically that the new per-year balance series
(`year_balances`/`year_totals`) survives the wire boundary (§4.2's
service-computed-but-dropped-on-the-wire gotcha), matching the
`test_calendar_router.py` pattern.
"""
from datetime import date, datetime
from uuid import uuid4
from httpx import AsyncClient
from app.entities.child import Child
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
from tests.conftest import SeededIds
class TestGetOverviewYearBalances:
async def test_wire_shape_includes_year_balances_and_year_totals(
self,
authed_client: AsyncClient,
child_repo: SQLiteChildRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date(2025, 1, 1)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws,
name="Kid",
birth_date=birth,
created_at=datetime.now(),
)
)
response = await authed_client.get(
f"/api/analytics/{ws}/overview", params={"child_id": str(child.id)}
)
assert response.status_code == 200
body = response.json()
assert "year_totals" in body
assert [yt["year"] for yt in body["year_totals"]] == [2025, 2026, 2027, 2028, 2029]
for yt in body["year_totals"]:
assert set(yt) == {"year", "pl_left", "vacation_left"}
assert len(body["parent_projections"]) == 1
projection = body["parent_projections"][0]
assert projection["personal_data_uuid"] == str(pd_uuid)
assert "year_balances" in projection
assert [yb["year"] for yb in projection["year_balances"]] == [
2025,
2026,
2027,
2028,
2029,
]
birth_year = projection["year_balances"][0]
assert birth_year["personal_data_uuid"] == str(pd_uuid)
assert birth_year["child_id"] == str(child.id)
vacation = birth_year["vacation"]
assert set(vacation) == {
"year",
"earned",
"is_assumed",
"carried_in",
"planned_use",
"left",
"carried_out",
"expiring",
}
assert vacation["earned"] == 25 # statutory default — no vacation_days row entered
assert vacation["is_assumed"] is True
assert vacation["carried_in"] == 0
assert vacation["planned_use"] == 0
assert vacation["left"] == 25
parental_leave = birth_year["parental_leave"]
assert set(parental_leave) == {
"year",
"sjukpenning_used",
"lagsta_used",
"sjukpenning_left",
"lagsta_left",
}
# `seeded_ids`'s workspace has exactly one member -> full pool.
assert parental_leave["sjukpenning_left"] == 390
assert parental_leave["lagsta_left"] == 90
@@ -12,6 +12,7 @@ from dateutil.relativedelta import relativedelta
from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.planned_leave import PlannedLeaveDay
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
@@ -71,8 +72,11 @@ async def _make_service(
planned_repo: SQLitePlannedLeaveRepository,
pd_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> 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
)
class TestAge4Projection:
@@ -83,6 +87,7 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -97,7 +102,12 @@ class TestAge4Projection:
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -115,6 +125,7 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -125,7 +136,12 @@ class TestAge4Projection:
# No FK data entered
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -145,6 +161,7 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -155,7 +172,12 @@ class TestAge4Projection:
await child_repo.create(child)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -169,6 +191,7 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -181,7 +204,12 @@ class TestAge4Projection:
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
@@ -196,11 +224,17 @@ class TestAge4Projection:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(NotFoundError):
await service.get_days_left_at_age_4(uuid4(), uuid4())
@@ -214,11 +248,17 @@ class TestValidateDubbeldagar:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(NotFoundError):
await service.validate_dubbeldagar(uuid4(), date.today())
@@ -230,6 +270,7 @@ class TestValidateDubbeldagar:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
from app.utils.errors import DubbeldagError
@@ -240,7 +281,12 @@ class TestValidateDubbeldagar:
await child_repo.create(child)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
# Date beyond the 15-month window
beyond_window = birth + relativedelta(months=16)
@@ -254,6 +300,7 @@ class TestValidateDubbeldagar:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
from app.config import settings
@@ -282,7 +329,12 @@ class TestValidateDubbeldagar:
)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
within_window = birth + relativedelta(months=1)
with pytest.raises(DubbeldagError):
@@ -297,6 +349,7 @@ class TestGetOverview:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -310,7 +363,12 @@ class TestGetOverview:
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
overview = await service.get_overview(ws, child.id)
@@ -327,11 +385,17 @@ class TestGetOverview:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.utils.errors import NotFoundError
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
with pytest.raises(NotFoundError):
await service.get_overview(uuid4(), uuid4())
@@ -353,6 +417,7 @@ class TestCoupleWorkspacePerParentCap:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
@@ -378,7 +443,12 @@ class TestCoupleWorkspacePerParentCap:
await parental_leave_repo.create(fetch_b)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj_a = await service.get_days_left_at_age_4(parent_a.uuid, child.id)
@@ -402,6 +472,7 @@ class TestCoupleWorkspacePerParentCap:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
"""Safety belt: a workspace with only one member so far (partner not
@@ -423,7 +494,12 @@ class TestCoupleWorkspacePerParentCap:
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(parent.uuid, child.id)
assert proj.savable_cap_at_b4 == 96
@@ -446,6 +522,7 @@ class TestSingleParentWorkspaceCap:
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
@@ -457,7 +534,12 @@ class TestSingleParentWorkspaceCap:
await parental_leave_repo.create(fetch)
service = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert proj.savable_cap_at_b4 == 96
@@ -515,7 +597,12 @@ class TestAgreesWithPlanProposalForTheSameCommittedState:
# -- Dashboard side --
analytics = await _make_service(
child_repo, parental_leave_repo, planned_leave_repo, personal_data_repo, workspace_repo
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id)
@@ -545,3 +632,467 @@ class TestAgreesWithPlanProposalForTheSameCommittedState:
# fallback (240 vs. 480) fails loudly rather than merely disagreeing.
assert dashboard_proj.projected_left_at_b4 == 480
assert dashboard_proj.expiring_at_b4 == 384
async def test_vacation_left_matches_plan_proposal_in_the_birth_year(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
"""The birth year is the FIRST year of `year_balances`' series, so its
`carried_in` is read straight off the `vacation_days` row's
`saved_days_in` (no multi-year FIFO simulation has happened yet) —
exactly what `AllocationInputLoader.load`'s `VacationBudget` also
does. The two surfaces must therefore agree for that one year.
(A LATER year's `left` is not asserted here: analytics simulates a
FIFO carry-forward bank across the whole series, while the plan
proposal reads whatever `saved_days_in` the user entered for that
specific year directly — they only need to agree where analytics
hasn't yet layered its own simulation on top, i.e. the first year.)
"""
from app.entities.personal_data import PersonalData
from app.entities.vacation_days import VacationDays
from app.entities.workspace import Workspace
from app.services.allocation_service import AllocationInputLoader, Allocator
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Sam",
salary_type="monthly",
employment_status="employed",
created_at=now,
)
)
birth = date.today() + relativedelta(years=2)
child = _make_child(ws.id, birth)
await child_repo.create(child)
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=birth.year,
total_days=25,
saved_days_in=4,
)
)
analytics = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
dashboard_proj = await analytics.get_days_left_at_age_4(parent.uuid, child.id)
birth_year_balance = next(
yb for yb in dashboard_proj.year_balances if yb.year == birth.year
)
loader = AllocationInputLoader(
workspace_repo=workspace_repo,
child_repo=child_repo,
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
vacation_repo=vacation_repo,
planned_leave_repo=planned_leave_repo,
leave_wish_repo=leave_wish_repo,
)
alloc_input = await loader.load(ws.id, today=date.today())
proposal = Allocator().propose(alloc_input)
plan_vacation = proposal.balances_after.vacation_after[(parent.uuid, birth.year)]
assert birth_year_balance.vacation.left == plan_vacation.available
assert birth_year_balance.vacation.left == 29 # 25 + 4 saved_days_in
class TestVacationYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._vacation_year_series` — the
FIFO carry-forward bank simulation documented on `ParentYearBalance`.
"""
def test_no_rows_assumes_statutory_default_and_floor(self) -> None:
years = [2025, 2026, 2027]
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
first = result[0]
assert first.earned == 25 # rules.statutory_vacation_days_per_year default
assert first.is_assumed is True
assert first.carried_in == 0
assert first.left == 25
assert first.carried_out == 5 # max(25 - floor(20), 0)
assert first.expiring == 0
def test_carry_chain_accumulates_across_years(self) -> None:
years = [2025, 2026, 2027]
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
# Unused years keep saving 5/year (25 earned - 20 floor) and the
# prior years' saved days carry forward untouched.
assert [r.carried_in for r in result] == [0, 5, 10]
assert [r.carried_out for r in result] == [5, 10, 15]
assert [r.left for r in result] == [25, 30, 35]
def test_saved_days_expire_after_five_years(self) -> None:
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
# Exactly carry_years + 1 years: only 2020's own batch can possibly
# have aged out by the last year in this series.
years = list(range(2020, 2020 + carry_years + 1))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
by_year = {r.year: r for r in result}
# 2020's 5 saved days must not still be sitting in `carried_in`
# `carry_years` years later, and must have been reported as
# `expiring` exactly once, in the year they lapsed.
expiring_years = [y for y, r in by_year.items() if r.expiring > 0]
assert expiring_years == [2020 + carry_years]
assert by_year[2020 + carry_years].expiring == 5
def test_continuous_saving_expires_the_oldest_batch_every_year_thereafter(self) -> None:
"""Once the bank has been running >= carry_years, an unbroken
surplus (earned always above floor, nothing spent) ages out exactly
one batch per year from then on — steady state, not a one-off event.
"""
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
years = list(range(2020, 2020 + carry_years + 3))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
by_year = {r.year: r for r in result}
for year in years[carry_years:]:
assert by_year[year].expiring == 5
# Carried-in plateaus at exactly carry_years batches of 5.
assert by_year[years[-1]].carried_in == 5 * carry_years
def test_saved_days_in_override_on_later_year(self) -> None:
from app.entities.vacation_days import VacationDays
years = [2025, 2026, 2027]
# No row for 2025 (the seed year); a later year's row overrides the
# computed carry-in per `ParentYearBalance`'s docstring.
rows = {
2026: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2026,
total_days=25,
saved_days_in=12,
),
}
result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20)
by_year = {r.year: r for r in result}
assert by_year[2025].carried_in == 0 # nothing entered/simulated yet
assert by_year[2026].carried_in == 12 # override wins over computed (5)
assert by_year[2026].is_assumed is False
# 2027 continues the FIFO simulation from the overridden figure.
assert by_year[2027].carried_in == 17 # 12 (override) + 5 (2026's own saveable)
def test_zero_saved_days_in_on_later_year_does_not_override(self) -> None:
from app.entities.vacation_days import VacationDays
years = [2025, 2026]
rows = {
2026: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2026,
total_days=25,
saved_days_in=0,
),
}
result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20)
by_year = {r.year: r for r in result}
# 2025 saved 5 (25 - floor 20) with no usage; a zero saved_days_in on
# 2026's row must not wipe that computed carry-in out.
assert by_year[2026].carried_in == 5
class TestParentalLeaveYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._pl_year_series`."""
def test_no_snapshot_deducts_from_birth_using_default_share(self) -> None:
birth = date(2025, 1, 1)
b4 = date(2029, 1, 1)
years = [2025, 2026, 2027, 2028, 2029]
pid, cid = uuid4(), uuid4()
rows = [
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=cid,
parent_personal_data_uuid=pid,
date=date(2026, 6, 1),
day_type="sjukpenning",
)
]
result = AnalyticsService._pl_year_series(
years, rows, birth, b4, savable_cap=96, latest_fetch=None, default_share=(390, 90)
)
by_year = {r.year: r for r in result}
assert by_year[2025].sjukpenning_left == 390 # nothing planned yet
assert by_year[2026].sjukpenning_used == 1
assert by_year[2026].sjukpenning_left == 389 # deducted from birth (no snapshot)
def test_snapshot_mid_series_only_deducts_post_snapshot_rows(self) -> None:
from app.entities.parental_leave import ParentalLeaveFetch, TierData
birth = date(2025, 1, 1)
b4 = date(2029, 1, 1)
years = [2025, 2026, 2027, 2028, 2029]
pid, cid = uuid4(), uuid4()
fetch = ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=pid,
workspace_id=uuid4(),
child_id=cid,
source="manual",
fetched_at=datetime(2026, 6, 1),
total_days=480,
days_used=10,
days_left=470,
reserved_days_total=0,
reserved_days_left=0,
transferable_days_left=0,
dubbeldagar_left=60,
tier_data=TierData(
sjukpenning_total=390,
sjukpenning_used=10,
sjukpenning_left=380,
lagsta_niva_total=90,
lagsta_niva_used=0,
lagsta_niva_left=90,
),
)
rows = [
# Before the snapshot — already reflected in `sjukpenning_left`
# above, must NOT be deducted again.
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=cid,
parent_personal_data_uuid=pid,
date=date(2026, 1, 1),
day_type="sjukpenning",
),
# After the snapshot — must be deducted.
PlannedLeaveDay(
id=uuid4(),
workspace_id=uuid4(),
child_id=cid,
parent_personal_data_uuid=pid,
date=date(2026, 7, 1),
day_type="sjukpenning",
),
]
result = AnalyticsService._pl_year_series(
years, rows, birth, b4, savable_cap=96, latest_fetch=fetch, default_share=None
)
by_year = {r.year: r for r in result}
assert by_year[2026].sjukpenning_used == 2 # both rows counted for display
assert by_year[2026].sjukpenning_left == 379 # only the post-snapshot row deducted
assert by_year[2027].sjukpenning_left == 379 # unchanged, carries forward
def test_age4_clamp_cuts_lagsta_first_in_b4_year(self) -> None:
from app.entities.parental_leave import ParentalLeaveFetch, TierData
birth = date(2025, 1, 1)
b4 = date(2029, 1, 1)
years = [2025, 2026, 2027, 2028, 2029]
pid, cid = uuid4(), uuid4()
fetch = ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=pid,
workspace_id=uuid4(),
child_id=cid,
source="manual",
fetched_at=datetime(2025, 1, 1),
total_days=480,
days_used=0,
days_left=480,
reserved_days_total=0,
reserved_days_left=0,
transferable_days_left=0,
dubbeldagar_left=60,
tier_data=TierData(
sjukpenning_total=390,
sjukpenning_used=0,
sjukpenning_left=390,
lagsta_niva_total=90,
lagsta_niva_used=0,
lagsta_niva_left=90,
),
)
result = AnalyticsService._pl_year_series(
years, [], birth, b4, savable_cap=48, latest_fetch=fetch, default_share=None
)
by_year = {r.year: r for r in result}
# Untouched pool (390 + 90 = 480) clamped to 48 at b4, lagsta cut first.
assert by_year[2029].lagsta_left == 0
assert by_year[2029].sjukpenning_left == 48
# Years before b4 are unaffected by the clamp.
assert by_year[2028].sjukpenning_left == 390
assert by_year[2028].lagsta_left == 90
class TestYearBalancesOnProjection:
"""`Age4Projection.year_balances` end-to-end through the real repos."""
async def test_series_spans_birth_year_through_b4_year(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date(2025, 3, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
assert [yb.year for yb in proj.year_balances] == [2025, 2026, 2027, 2028, 2029]
assert all(yb.personal_data_uuid == pd_uuid for yb in proj.year_balances)
assert all(yb.child_id == child.id for yb in proj.year_balances)
async def test_vacation_planned_use_counts_rows_across_all_children(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
"""Vacation is budgeted per (parent, year), not per child — see
`PlanningService._vacation_days_left`. A vacation day planned
against a DIFFERENT child of the same parent, in the same year,
must still reduce `planned_use` here.
"""
ws = seeded_ids.workspace_id
pd_uuid = seeded_ids.personal_data_uuid
birth = date(2025, 3, 1)
child = _make_child(ws, birth)
await child_repo.create(child)
other_child = _make_child(ws, date(2020, 1, 1))
await child_repo.create(other_child)
from app.entities.planned_leave import PlannedLeaveDay
await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=other_child.id,
parent_personal_data_uuid=pd_uuid,
date=date(2025, 6, 1),
day_type="vacation",
)
)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
year_2025 = next(yb for yb in proj.year_balances if yb.year == 2025)
assert year_2025.vacation.planned_use == 1
class TestOverviewYearTotals:
async def test_combines_both_parents_per_year(
self,
child_repo: SQLiteChildRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent_a = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now)
)
parent_b = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="B", created_at=now)
)
birth = date(2025, 1, 1)
child = _make_child(ws.id, birth)
await child_repo.create(child)
service = await _make_service(
child_repo,
parental_leave_repo,
planned_leave_repo,
personal_data_repo,
workspace_repo,
vacation_repo,
)
overview = await service.get_overview(ws.id, child.id)
assert [yt.year for yt in overview.year_totals] == [2025, 2026, 2027, 2028, 2029]
birth_year_totals = overview.year_totals[0]
parent_a_proj = next(
p for p in overview.parent_projections if p.personal_data_uuid == parent_a.uuid
)
parent_b_proj = next(
p for p in overview.parent_projections if p.personal_data_uuid == parent_b.uuid
)
a_2025 = next(yb for yb in parent_a_proj.year_balances if yb.year == 2025)
b_2025 = next(yb for yb in parent_b_proj.year_balances if yb.year == 2025)
expected_pl_left = (
a_2025.parental_leave.sjukpenning_left
+ a_2025.parental_leave.lagsta_left
+ b_2025.parental_leave.sjukpenning_left
+ b_2025.parental_leave.lagsta_left
)
expected_vacation_left = a_2025.vacation.left + b_2025.vacation.left
assert birth_year_totals.pl_left == expected_pl_left
assert birth_year_totals.vacation_left == expected_vacation_left