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