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:
@@ -62,6 +62,12 @@ class LeaveRules(BaseModel):
|
|||||||
# allocator actually enforces (PLANNING_ENGINE_SPEC.md §9.2).
|
# allocator actually enforces (PLANNING_ENGINE_SPEC.md §9.2).
|
||||||
min_vacation_days_per_year: int = 20
|
min_vacation_days_per_year: int = 20
|
||||||
|
|
||||||
|
# Semesterlagen: a vacation day saved above the floor (see
|
||||||
|
# `min_vacation_days_per_year`) may be carried forward for this many
|
||||||
|
# years before it expires. Used by `AnalyticsService`'s per-year vacation
|
||||||
|
# carry-forward bank (§ per-parent-per-year balance series).
|
||||||
|
vacation_carry_forward_years: int = 5
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_days_per_child(self) -> int:
|
def total_days_per_child(self) -> int:
|
||||||
return self.sjukpenning_days + self.lagsta_niva_days
|
return self.sjukpenning_days + self.lagsta_niva_days
|
||||||
|
|||||||
@@ -354,8 +354,11 @@ def get_analytics_service(
|
|||||||
child_repo: ChildRepositoryDep,
|
child_repo: ChildRepositoryDep,
|
||||||
pd_repo: PersonalDataRepositoryDep,
|
pd_repo: PersonalDataRepositoryDep,
|
||||||
workspace_repo: WorkspaceRepositoryDep,
|
workspace_repo: WorkspaceRepositoryDep,
|
||||||
|
vacation_repo: VacationDaysRepositoryDep,
|
||||||
) -> AnalyticsService:
|
) -> AnalyticsService:
|
||||||
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
return AnalyticsService(
|
||||||
|
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)]
|
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)]
|
||||||
|
|||||||
@@ -5,7 +5,84 @@ from uuid import UUID
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.services.analytics_service import Age4Projection, WorkspaceOverview
|
from app.services.analytics_service import (
|
||||||
|
Age4Projection,
|
||||||
|
ParentalLeaveYearBalance,
|
||||||
|
ParentYearBalance,
|
||||||
|
VacationYearBalance,
|
||||||
|
WorkspaceOverview,
|
||||||
|
YearTotals,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VacationYearBalanceResponse(BaseModel):
|
||||||
|
year: int
|
||||||
|
earned: int
|
||||||
|
is_assumed: bool
|
||||||
|
carried_in: int
|
||||||
|
planned_use: int
|
||||||
|
left: int
|
||||||
|
carried_out: int
|
||||||
|
expiring: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_entity(cls, v: VacationYearBalance) -> "VacationYearBalanceResponse":
|
||||||
|
return cls(
|
||||||
|
year=v.year,
|
||||||
|
earned=v.earned,
|
||||||
|
is_assumed=v.is_assumed,
|
||||||
|
carried_in=v.carried_in,
|
||||||
|
planned_use=v.planned_use,
|
||||||
|
left=v.left,
|
||||||
|
carried_out=v.carried_out,
|
||||||
|
expiring=v.expiring,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParentalLeaveYearBalanceResponse(BaseModel):
|
||||||
|
year: int
|
||||||
|
sjukpenning_used: int
|
||||||
|
lagsta_used: int
|
||||||
|
sjukpenning_left: int
|
||||||
|
lagsta_left: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_entity(cls, p: ParentalLeaveYearBalance) -> "ParentalLeaveYearBalanceResponse":
|
||||||
|
return cls(
|
||||||
|
year=p.year,
|
||||||
|
sjukpenning_used=p.sjukpenning_used,
|
||||||
|
lagsta_used=p.lagsta_used,
|
||||||
|
sjukpenning_left=p.sjukpenning_left,
|
||||||
|
lagsta_left=p.lagsta_left,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParentYearBalanceResponse(BaseModel):
|
||||||
|
personal_data_uuid: UUID
|
||||||
|
child_id: UUID
|
||||||
|
year: int
|
||||||
|
vacation: VacationYearBalanceResponse
|
||||||
|
parental_leave: ParentalLeaveYearBalanceResponse
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_entity(cls, p: ParentYearBalance) -> "ParentYearBalanceResponse":
|
||||||
|
return cls(
|
||||||
|
personal_data_uuid=p.personal_data_uuid,
|
||||||
|
child_id=p.child_id,
|
||||||
|
year=p.year,
|
||||||
|
vacation=VacationYearBalanceResponse.from_entity(p.vacation),
|
||||||
|
parental_leave=ParentalLeaveYearBalanceResponse.from_entity(p.parental_leave),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YearTotalsResponse(BaseModel):
|
||||||
|
year: int
|
||||||
|
pl_left: int
|
||||||
|
vacation_left: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_entity(cls, y: YearTotals) -> "YearTotalsResponse":
|
||||||
|
return cls(year=y.year, pl_left=y.pl_left, vacation_left=y.vacation_left)
|
||||||
|
|
||||||
|
|
||||||
class Age4ProjectionResponse(BaseModel):
|
class Age4ProjectionResponse(BaseModel):
|
||||||
@@ -20,6 +97,7 @@ class Age4ProjectionResponse(BaseModel):
|
|||||||
savable_after_b4: int
|
savable_after_b4: int
|
||||||
expiring_at_b4: int
|
expiring_at_b4: int
|
||||||
savable_cap_at_b4: int
|
savable_cap_at_b4: int
|
||||||
|
year_balances: list[ParentYearBalanceResponse]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_entity(cls, p: Age4Projection) -> "Age4ProjectionResponse":
|
def from_entity(cls, p: Age4Projection) -> "Age4ProjectionResponse":
|
||||||
@@ -35,6 +113,7 @@ class Age4ProjectionResponse(BaseModel):
|
|||||||
savable_after_b4=p.savable_after_b4,
|
savable_after_b4=p.savable_after_b4,
|
||||||
expiring_at_b4=p.expiring_at_b4,
|
expiring_at_b4=p.expiring_at_b4,
|
||||||
savable_cap_at_b4=p.savable_cap_at_b4,
|
savable_cap_at_b4=p.savable_cap_at_b4,
|
||||||
|
year_balances=[ParentYearBalanceResponse.from_entity(yb) for yb in p.year_balances],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +124,7 @@ class WorkspaceOverviewResponse(BaseModel):
|
|||||||
combined_days_used: int
|
combined_days_used: int
|
||||||
combined_days_left: int
|
combined_days_left: int
|
||||||
combined_expiring_at_b4: int
|
combined_expiring_at_b4: int
|
||||||
|
year_totals: list[YearTotalsResponse]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_entity(cls, overview: WorkspaceOverview) -> "WorkspaceOverviewResponse":
|
def from_entity(cls, overview: WorkspaceOverview) -> "WorkspaceOverviewResponse":
|
||||||
@@ -57,4 +137,5 @@ class WorkspaceOverviewResponse(BaseModel):
|
|||||||
combined_days_used=overview.combined_days_used,
|
combined_days_used=overview.combined_days_used,
|
||||||
combined_days_left=overview.combined_days_left,
|
combined_days_left=overview.combined_days_left,
|
||||||
combined_expiring_at_b4=overview.combined_expiring_at_b4,
|
combined_expiring_at_b4=overview.combined_expiring_at_b4,
|
||||||
|
year_totals=[YearTotalsResponse.from_entity(y) for y in overview.year_totals],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.dependencies import (
|
|||||||
ParentalLeaveRepositoryDep,
|
ParentalLeaveRepositoryDep,
|
||||||
PersonalDataRepositoryDep,
|
PersonalDataRepositoryDep,
|
||||||
PlannedLeaveRepositoryDep,
|
PlannedLeaveRepositoryDep,
|
||||||
|
VacationDaysRepositoryDep,
|
||||||
WorkspaceIdDep,
|
WorkspaceIdDep,
|
||||||
WorkspaceRepositoryDep,
|
WorkspaceRepositoryDep,
|
||||||
WorkspaceScopeDep,
|
WorkspaceScopeDep,
|
||||||
@@ -26,8 +27,11 @@ def _make_service(
|
|||||||
child_repo: ChildRepositoryDep,
|
child_repo: ChildRepositoryDep,
|
||||||
pd_repo: PersonalDataRepositoryDep,
|
pd_repo: PersonalDataRepositoryDep,
|
||||||
workspace_repo: WorkspaceRepositoryDep,
|
workspace_repo: WorkspaceRepositoryDep,
|
||||||
|
vacation_repo: VacationDaysRepositoryDep,
|
||||||
) -> AnalyticsService:
|
) -> AnalyticsService:
|
||||||
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
return AnalyticsService(
|
||||||
|
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{workspace_id}/overview", response_model=WorkspaceOverviewResponse)
|
@router.get("/{workspace_id}/overview", response_model=WorkspaceOverviewResponse)
|
||||||
@@ -39,8 +43,11 @@ async def get_overview(
|
|||||||
child_repo: ChildRepositoryDep,
|
child_repo: ChildRepositoryDep,
|
||||||
pd_repo: PersonalDataRepositoryDep,
|
pd_repo: PersonalDataRepositoryDep,
|
||||||
workspace_repo: WorkspaceRepositoryDep,
|
workspace_repo: WorkspaceRepositoryDep,
|
||||||
|
vacation_repo: VacationDaysRepositoryDep,
|
||||||
) -> WorkspaceOverviewResponse:
|
) -> WorkspaceOverviewResponse:
|
||||||
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
service = _make_service(
|
||||||
|
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
overview = await service.get_overview(workspace_id, child_id)
|
overview = await service.get_overview(workspace_id, child_id)
|
||||||
except NotFoundError as exc:
|
except NotFoundError as exc:
|
||||||
@@ -57,8 +64,11 @@ async def get_projection(
|
|||||||
child_repo: ChildRepositoryDep,
|
child_repo: ChildRepositoryDep,
|
||||||
pd_repo: PersonalDataRepositoryDep,
|
pd_repo: PersonalDataRepositoryDep,
|
||||||
workspace_repo: WorkspaceRepositoryDep,
|
workspace_repo: WorkspaceRepositoryDep,
|
||||||
|
vacation_repo: VacationDaysRepositoryDep,
|
||||||
) -> WorkspaceOverviewResponse:
|
) -> WorkspaceOverviewResponse:
|
||||||
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
service = _make_service(
|
||||||
|
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
overview = await service.get_overview(workspace_id, child_id)
|
overview = await service.get_overview(workspace_id, child_id)
|
||||||
except NotFoundError as exc:
|
except NotFoundError as exc:
|
||||||
@@ -77,11 +87,14 @@ async def get_age4_projection(
|
|||||||
child_repo: ChildRepositoryDep,
|
child_repo: ChildRepositoryDep,
|
||||||
pd_repo: PersonalDataRepositoryDep,
|
pd_repo: PersonalDataRepositoryDep,
|
||||||
workspace_repo: WorkspaceRepositoryDep,
|
workspace_repo: WorkspaceRepositoryDep,
|
||||||
|
vacation_repo: VacationDaysRepositoryDep,
|
||||||
scope: WorkspaceScopeDep,
|
scope: WorkspaceScopeDep,
|
||||||
) -> Age4ProjectionResponse:
|
) -> Age4ProjectionResponse:
|
||||||
await scope.check_member(personal_data_uuid)
|
await scope.check_member(personal_data_uuid)
|
||||||
await scope.check_child(child_id)
|
await scope.check_child(child_id)
|
||||||
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
service = _make_service(
|
||||||
|
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
proj = await service.get_days_left_at_age_4(
|
proj = await service.get_days_left_at_age_4(
|
||||||
personal_data_uuid, child_id, workspace_id=scope.workspace_id
|
personal_data_uuid, child_id, workspace_id=scope.workspace_id
|
||||||
|
|||||||
@@ -18,17 +18,22 @@ dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from app.config import settings
|
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.child_repository import ChildRepository
|
||||||
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
||||||
from app.repositories.personal_data_repository import PersonalDataRepository
|
from app.repositories.personal_data_repository import PersonalDataRepository
|
||||||
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
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.repositories.workspace_repository import WorkspaceRepository
|
||||||
from app.utils.errors import DubbeldagError, NotFoundError
|
from app.utils.errors import DubbeldagError, NotFoundError
|
||||||
from app.utils.tip_predicates import (
|
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
|
@dataclass
|
||||||
class Age4Projection:
|
class Age4Projection:
|
||||||
"""Per-parent projection of days remaining at the child's 4th birthday."""
|
"""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
|
# `resolve_age4_savable_cap`. Surfaced so the frontend can render "X kvar
|
||||||
# / Y sparbara" without re-deriving the rule itself.
|
# / Y sparbara" without re-deriving the rule itself.
|
||||||
savable_cap_at_b4: int
|
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
|
@dataclass
|
||||||
@@ -67,6 +154,8 @@ class WorkspaceOverview:
|
|||||||
combined_days_used: int
|
combined_days_used: int
|
||||||
combined_days_left: int
|
combined_days_left: int
|
||||||
combined_expiring_at_b4: int
|
combined_expiring_at_b4: int
|
||||||
|
# Combined (all parents) per-year totals — see `YearTotals`.
|
||||||
|
year_totals: list[YearTotals] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class AnalyticsService:
|
class AnalyticsService:
|
||||||
@@ -77,12 +166,14 @@ class AnalyticsService:
|
|||||||
child_repo: ChildRepository,
|
child_repo: ChildRepository,
|
||||||
personal_data_repo: PersonalDataRepository,
|
personal_data_repo: PersonalDataRepository,
|
||||||
workspace_repo: WorkspaceRepository,
|
workspace_repo: WorkspaceRepository,
|
||||||
|
vacation_days_repo: VacationDaysRepository,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._leave = parental_leave_repo
|
self._leave = parental_leave_repo
|
||||||
self._planned = planned_leave_repo
|
self._planned = planned_leave_repo
|
||||||
self._children = child_repo
|
self._children = child_repo
|
||||||
self._parents = personal_data_repo
|
self._parents = personal_data_repo
|
||||||
self._workspace_repo = workspace_repo
|
self._workspace_repo = workspace_repo
|
||||||
|
self._vacation = vacation_days_repo
|
||||||
|
|
||||||
async def get_parent_count(self, workspace_id: UUID) -> int:
|
async def get_parent_count(self, workspace_id: UUID) -> int:
|
||||||
"""Number of (non-deleted) parents on a workspace.
|
"""Number of (non-deleted) parents on a workspace.
|
||||||
@@ -134,6 +225,7 @@ class AnalyticsService:
|
|||||||
b4 = child.birth_date + relativedelta(years=4)
|
b4 = child.birth_date + relativedelta(years=4)
|
||||||
|
|
||||||
latest = await self._leave.get_latest_by_parent(personal_data_uuid, child_id)
|
latest = await self._leave.get_latest_by_parent(personal_data_uuid, child_id)
|
||||||
|
default_share: tuple[int, int] | None = None
|
||||||
if latest is None:
|
if latest is None:
|
||||||
# No FK data yet — assume full entitlement (0 used) for this
|
# No FK data yet — assume full entitlement (0 used) for this
|
||||||
# parent's share, via the same shared helper `AllocationInputLoader.
|
# 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)
|
parent_count = await self.get_parent_count(child.workspace_id)
|
||||||
sjuk_share, lagsta_share = default_tier_pool_share(rules, parent_count)
|
sjuk_share, lagsta_share = default_tier_pool_share(rules, parent_count)
|
||||||
total_days = sjuk_share + lagsta_share
|
total_days = sjuk_share + lagsta_share
|
||||||
|
default_share = (sjuk_share, lagsta_share)
|
||||||
else:
|
else:
|
||||||
days_used = latest.days_used
|
days_used = latest.days_used
|
||||||
total_days = latest.total_days
|
total_days = latest.total_days
|
||||||
@@ -180,6 +273,10 @@ class AnalyticsService:
|
|||||||
)
|
)
|
||||||
savable = min(projected_left, 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(
|
return Age4Projection(
|
||||||
personal_data_uuid=personal_data_uuid,
|
personal_data_uuid=personal_data_uuid,
|
||||||
child_id=child_id,
|
child_id=child_id,
|
||||||
@@ -192,8 +289,228 @@ class AnalyticsService:
|
|||||||
savable_after_b4=savable,
|
savable_after_b4=savable,
|
||||||
expiring_at_b4=expiring,
|
expiring_at_b4=expiring,
|
||||||
savable_cap_at_b4=savable_cap,
|
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:
|
async def get_overview(self, workspace_id: UUID, child_id: UUID) -> WorkspaceOverview:
|
||||||
"""Compute projections for all parents in a workspace for one child."""
|
"""Compute projections for all parents in a workspace for one child."""
|
||||||
members = await self._parents.list_by_workspace(workspace_id)
|
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_left = sum(p.projected_left_at_b4 for p in projections)
|
||||||
combined_expiring = sum(p.expiring_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(
|
return WorkspaceOverview(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
child_id=child_id,
|
child_id=child_id,
|
||||||
@@ -216,6 +545,7 @@ class AnalyticsService:
|
|||||||
combined_days_used=combined_used,
|
combined_days_used=combined_used,
|
||||||
combined_days_left=combined_left,
|
combined_days_left=combined_left,
|
||||||
combined_expiring_at_b4=combined_expiring,
|
combined_expiring_at_b4=combined_expiring,
|
||||||
|
year_totals=year_totals,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def validate_dubbeldagar(self, child_id: UUID, new_date: date) -> None:
|
async def validate_dubbeldagar(self, child_id: UUID, new_date: date) -> None:
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ def _build_services(conn: aiosqlite.Connection) -> DemoServices:
|
|||||||
parental_leave = SQLiteParentalLeaveRepository(conn)
|
parental_leave = SQLiteParentalLeaveRepository(conn)
|
||||||
planned_leave = SQLitePlannedLeaveRepository(conn)
|
planned_leave = SQLitePlannedLeaveRepository(conn)
|
||||||
|
|
||||||
analytics = AnalyticsService(parental_leave, planned_leave, children, personal_data, workspaces)
|
analytics = AnalyticsService(
|
||||||
|
parental_leave, planned_leave, children, personal_data, workspaces, vacation_repo
|
||||||
|
)
|
||||||
|
|
||||||
return DemoServices(
|
return DemoServices(
|
||||||
auth=AuthService(users, personal_data, workspaces),
|
auth=AuthService(users, personal_data, workspaces),
|
||||||
|
|||||||
@@ -337,9 +337,15 @@ async def analytics_service(
|
|||||||
child_repo: SQLiteChildRepository,
|
child_repo: SQLiteChildRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
) -> AnalyticsService:
|
) -> AnalyticsService:
|
||||||
return 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.child import Child
|
||||||
from app.entities.parental_leave import ParentalLeaveFetch, TierData
|
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.child_repository import SQLiteChildRepository
|
||||||
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
|
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
|
||||||
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
||||||
@@ -71,8 +72,11 @@ async def _make_service(
|
|||||||
planned_repo: SQLitePlannedLeaveRepository,
|
planned_repo: SQLitePlannedLeaveRepository,
|
||||||
pd_repo: SQLitePersonalDataRepository,
|
pd_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
) -> AnalyticsService:
|
) -> 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:
|
class TestAge4Projection:
|
||||||
@@ -83,6 +87,7 @@ class TestAge4Projection:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
seeded_ids: SeededIds,
|
seeded_ids: SeededIds,
|
||||||
) -> None:
|
) -> None:
|
||||||
ws = seeded_ids.workspace_id
|
ws = seeded_ids.workspace_id
|
||||||
@@ -97,7 +102,12 @@ class TestAge4Projection:
|
|||||||
await parental_leave_repo.create(fetch)
|
await parental_leave_repo.create(fetch)
|
||||||
|
|
||||||
service = await _make_service(
|
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)
|
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
|
||||||
|
|
||||||
@@ -115,6 +125,7 @@ class TestAge4Projection:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
seeded_ids: SeededIds,
|
seeded_ids: SeededIds,
|
||||||
) -> None:
|
) -> None:
|
||||||
ws = seeded_ids.workspace_id
|
ws = seeded_ids.workspace_id
|
||||||
@@ -125,7 +136,12 @@ class TestAge4Projection:
|
|||||||
|
|
||||||
# No FK data entered
|
# No FK data entered
|
||||||
service = await _make_service(
|
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)
|
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
|
||||||
|
|
||||||
@@ -145,6 +161,7 @@ class TestAge4Projection:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
seeded_ids: SeededIds,
|
seeded_ids: SeededIds,
|
||||||
) -> None:
|
) -> None:
|
||||||
ws = seeded_ids.workspace_id
|
ws = seeded_ids.workspace_id
|
||||||
@@ -155,7 +172,12 @@ class TestAge4Projection:
|
|||||||
await child_repo.create(child)
|
await child_repo.create(child)
|
||||||
|
|
||||||
service = await _make_service(
|
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)
|
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
|
||||||
|
|
||||||
@@ -169,6 +191,7 @@ class TestAge4Projection:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
seeded_ids: SeededIds,
|
seeded_ids: SeededIds,
|
||||||
) -> None:
|
) -> None:
|
||||||
ws = seeded_ids.workspace_id
|
ws = seeded_ids.workspace_id
|
||||||
@@ -181,7 +204,12 @@ class TestAge4Projection:
|
|||||||
await parental_leave_repo.create(fetch)
|
await parental_leave_repo.create(fetch)
|
||||||
|
|
||||||
service = await _make_service(
|
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)
|
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
|
||||||
|
|
||||||
@@ -196,11 +224,17 @@ class TestAge4Projection:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
) -> None:
|
) -> None:
|
||||||
from app.utils.errors import NotFoundError
|
from app.utils.errors import NotFoundError
|
||||||
|
|
||||||
service = await _make_service(
|
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):
|
with pytest.raises(NotFoundError):
|
||||||
await service.get_days_left_at_age_4(uuid4(), uuid4())
|
await service.get_days_left_at_age_4(uuid4(), uuid4())
|
||||||
@@ -214,11 +248,17 @@ class TestValidateDubbeldagar:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
) -> None:
|
) -> None:
|
||||||
from app.utils.errors import NotFoundError
|
from app.utils.errors import NotFoundError
|
||||||
|
|
||||||
service = await _make_service(
|
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):
|
with pytest.raises(NotFoundError):
|
||||||
await service.validate_dubbeldagar(uuid4(), date.today())
|
await service.validate_dubbeldagar(uuid4(), date.today())
|
||||||
@@ -230,6 +270,7 @@ class TestValidateDubbeldagar:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
seeded_ids: SeededIds,
|
seeded_ids: SeededIds,
|
||||||
) -> None:
|
) -> None:
|
||||||
from app.utils.errors import DubbeldagError
|
from app.utils.errors import DubbeldagError
|
||||||
@@ -240,7 +281,12 @@ class TestValidateDubbeldagar:
|
|||||||
await child_repo.create(child)
|
await child_repo.create(child)
|
||||||
|
|
||||||
service = await _make_service(
|
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
|
# Date beyond the 15-month window
|
||||||
beyond_window = birth + relativedelta(months=16)
|
beyond_window = birth + relativedelta(months=16)
|
||||||
@@ -254,6 +300,7 @@ class TestValidateDubbeldagar:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
seeded_ids: SeededIds,
|
seeded_ids: SeededIds,
|
||||||
) -> None:
|
) -> None:
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -282,7 +329,12 @@ class TestValidateDubbeldagar:
|
|||||||
)
|
)
|
||||||
|
|
||||||
service = await _make_service(
|
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)
|
within_window = birth + relativedelta(months=1)
|
||||||
with pytest.raises(DubbeldagError):
|
with pytest.raises(DubbeldagError):
|
||||||
@@ -297,6 +349,7 @@ class TestGetOverview:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
seeded_ids: SeededIds,
|
seeded_ids: SeededIds,
|
||||||
) -> None:
|
) -> None:
|
||||||
ws = seeded_ids.workspace_id
|
ws = seeded_ids.workspace_id
|
||||||
@@ -310,7 +363,12 @@ class TestGetOverview:
|
|||||||
await parental_leave_repo.create(fetch)
|
await parental_leave_repo.create(fetch)
|
||||||
|
|
||||||
service = await _make_service(
|
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)
|
overview = await service.get_overview(ws, child.id)
|
||||||
|
|
||||||
@@ -327,11 +385,17 @@ class TestGetOverview:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
) -> None:
|
) -> None:
|
||||||
from app.utils.errors import NotFoundError
|
from app.utils.errors import NotFoundError
|
||||||
|
|
||||||
service = await _make_service(
|
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):
|
with pytest.raises(NotFoundError):
|
||||||
await service.get_overview(uuid4(), uuid4())
|
await service.get_overview(uuid4(), uuid4())
|
||||||
@@ -353,6 +417,7 @@ class TestCoupleWorkspacePerParentCap:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
) -> None:
|
) -> None:
|
||||||
from app.entities.personal_data import PersonalData
|
from app.entities.personal_data import PersonalData
|
||||||
from app.entities.workspace import Workspace
|
from app.entities.workspace import Workspace
|
||||||
@@ -378,7 +443,12 @@ class TestCoupleWorkspacePerParentCap:
|
|||||||
await parental_leave_repo.create(fetch_b)
|
await parental_leave_repo.create(fetch_b)
|
||||||
|
|
||||||
service = await _make_service(
|
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)
|
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,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Safety belt: a workspace with only one member so far (partner not
|
"""Safety belt: a workspace with only one member so far (partner not
|
||||||
|
|
||||||
@@ -423,7 +494,12 @@ class TestCoupleWorkspacePerParentCap:
|
|||||||
await parental_leave_repo.create(fetch)
|
await parental_leave_repo.create(fetch)
|
||||||
|
|
||||||
service = await _make_service(
|
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)
|
proj = await service.get_days_left_at_age_4(parent.uuid, child.id)
|
||||||
assert proj.savable_cap_at_b4 == 96
|
assert proj.savable_cap_at_b4 == 96
|
||||||
@@ -446,6 +522,7 @@ class TestSingleParentWorkspaceCap:
|
|||||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||||
personal_data_repo: SQLitePersonalDataRepository,
|
personal_data_repo: SQLitePersonalDataRepository,
|
||||||
workspace_repo: SQLiteWorkspaceRepository,
|
workspace_repo: SQLiteWorkspaceRepository,
|
||||||
|
vacation_repo: SQLiteVacationDaysRepository,
|
||||||
seeded_ids: SeededIds,
|
seeded_ids: SeededIds,
|
||||||
) -> None:
|
) -> None:
|
||||||
ws = seeded_ids.workspace_id
|
ws = seeded_ids.workspace_id
|
||||||
@@ -457,7 +534,12 @@ class TestSingleParentWorkspaceCap:
|
|||||||
await parental_leave_repo.create(fetch)
|
await parental_leave_repo.create(fetch)
|
||||||
|
|
||||||
service = await _make_service(
|
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)
|
proj = await service.get_days_left_at_age_4(pd_uuid, child.id)
|
||||||
assert proj.savable_cap_at_b4 == 96
|
assert proj.savable_cap_at_b4 == 96
|
||||||
@@ -515,7 +597,12 @@ class TestAgreesWithPlanProposalForTheSameCommittedState:
|
|||||||
|
|
||||||
# -- Dashboard side --
|
# -- Dashboard side --
|
||||||
analytics = await _make_service(
|
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)
|
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.
|
# fallback (240 vs. 480) fails loudly rather than merely disagreeing.
|
||||||
assert dashboard_proj.projected_left_at_b4 == 480
|
assert dashboard_proj.projected_left_at_b4 == 480
|
||||||
assert dashboard_proj.expiring_at_b4 == 384
|
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
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ describe('Dashboard', () => {
|
|||||||
combinedDaysUsed: 30,
|
combinedDaysUsed: 30,
|
||||||
combinedDaysLeft: 270,
|
combinedDaysLeft: 270,
|
||||||
combinedExpiringAtB4: 100,
|
combinedExpiringAtB4: 100,
|
||||||
|
yearTotals: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -247,11 +248,12 @@ describe('Dashboard', () => {
|
|||||||
workspaceId: 'ws-1',
|
workspaceId: 'ws-1',
|
||||||
childId: 'c-1',
|
childId: 'c-1',
|
||||||
parentProjections: [
|
parentProjections: [
|
||||||
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
|
||||||
],
|
],
|
||||||
combinedDaysUsed: 30,
|
combinedDaysUsed: 30,
|
||||||
combinedDaysLeft: 210,
|
combinedDaysLeft: 210,
|
||||||
combinedExpiringAtB4: 60,
|
combinedExpiringAtB4: 60,
|
||||||
|
yearTotals: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -268,12 +270,13 @@ describe('Dashboard', () => {
|
|||||||
workspaceId: 'ws-1',
|
workspaceId: 'ws-1',
|
||||||
childId: 'c-1',
|
childId: 'c-1',
|
||||||
parentProjections: [
|
parentProjections: [
|
||||||
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48 },
|
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48, yearBalances: [] },
|
||||||
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48 },
|
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48, yearBalances: [] },
|
||||||
],
|
],
|
||||||
combinedDaysUsed: 31,
|
combinedDaysUsed: 31,
|
||||||
combinedDaysLeft: 279,
|
combinedDaysLeft: 279,
|
||||||
combinedExpiringAtB4: 191,
|
combinedExpiringAtB4: 191,
|
||||||
|
yearTotals: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -405,12 +408,13 @@ describe('Dashboard', () => {
|
|||||||
workspaceId: 'ws-1',
|
workspaceId: 'ws-1',
|
||||||
childId: 'c-1',
|
childId: 'c-1',
|
||||||
parentProjections: [
|
parentProjections: [
|
||||||
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
|
||||||
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] },
|
||||||
],
|
],
|
||||||
combinedDaysUsed: 50,
|
combinedDaysUsed: 50,
|
||||||
combinedDaysLeft: 190,
|
combinedDaysLeft: 190,
|
||||||
combinedExpiringAtB4: 104,
|
combinedExpiringAtB4: 104,
|
||||||
|
yearTotals: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -563,6 +567,7 @@ describe('Dashboard', () => {
|
|||||||
combinedDaysUsed: 50,
|
combinedDaysUsed: 50,
|
||||||
combinedDaysLeft: 200,
|
combinedDaysLeft: 200,
|
||||||
combinedExpiringAtB4: 104,
|
combinedExpiringAtB4: 104,
|
||||||
|
yearTotals: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -578,11 +583,12 @@ describe('Dashboard', () => {
|
|||||||
workspaceId: 'ws-1',
|
workspaceId: 'ws-1',
|
||||||
childId: 'c-1',
|
childId: 'c-1',
|
||||||
parentProjections: [
|
parentProjections: [
|
||||||
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104, savableCapAtB4: 96, yearBalances: [] },
|
||||||
],
|
],
|
||||||
combinedDaysUsed: 30,
|
combinedDaysUsed: 30,
|
||||||
combinedDaysLeft: 210,
|
combinedDaysLeft: 210,
|
||||||
combinedExpiringAtB4: 104,
|
combinedExpiringAtB4: 104,
|
||||||
|
yearTotals: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -599,12 +605,13 @@ describe('Dashboard', () => {
|
|||||||
workspaceId: 'ws-1',
|
workspaceId: 'ws-1',
|
||||||
childId: 'c-1',
|
childId: 'c-1',
|
||||||
parentProjections: [
|
parentProjections: [
|
||||||
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
|
||||||
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] },
|
||||||
],
|
],
|
||||||
combinedDaysUsed: 50,
|
combinedDaysUsed: 50,
|
||||||
combinedDaysLeft: 190,
|
combinedDaysLeft: 190,
|
||||||
combinedExpiringAtB4: 104,
|
combinedExpiringAtB4: 104,
|
||||||
|
yearTotals: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -633,12 +640,13 @@ describe('Dashboard', () => {
|
|||||||
workspaceId: 'ws-1',
|
workspaceId: 'ws-1',
|
||||||
childId: 'c-1',
|
childId: 'c-1',
|
||||||
parentProjections: [
|
parentProjections: [
|
||||||
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
|
||||||
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] },
|
||||||
],
|
],
|
||||||
combinedDaysUsed: 50,
|
combinedDaysUsed: 50,
|
||||||
combinedDaysLeft: 190,
|
combinedDaysLeft: 190,
|
||||||
combinedExpiringAtB4: 104,
|
combinedExpiringAtB4: 104,
|
||||||
|
yearTotals: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -655,12 +663,13 @@ describe('Dashboard', () => {
|
|||||||
workspaceId: 'ws-1',
|
workspaceId: 'ws-1',
|
||||||
childId: 'c-1',
|
childId: 'c-1',
|
||||||
parentProjections: [
|
parentProjections: [
|
||||||
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96, yearBalances: [] },
|
||||||
{ personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 },
|
{ personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96, yearBalances: [] },
|
||||||
],
|
],
|
||||||
combinedDaysUsed: 50,
|
combinedDaysUsed: 50,
|
||||||
combinedDaysLeft: 190,
|
combinedDaysLeft: 190,
|
||||||
combinedExpiringAtB4: 104,
|
combinedExpiringAtB4: 104,
|
||||||
|
yearTotals: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,11 +33,36 @@ describe('API response mappers (snake_case → camelCase)', () => {
|
|||||||
savable_after_b4: 96,
|
savable_after_b4: 96,
|
||||||
expiring_at_b4: 134,
|
expiring_at_b4: 134,
|
||||||
savable_cap_at_b4: 96,
|
savable_cap_at_b4: 96,
|
||||||
|
year_balances: [
|
||||||
|
{
|
||||||
|
personal_data_uuid: 'pd1',
|
||||||
|
child_id: 'child1',
|
||||||
|
year: 2024,
|
||||||
|
vacation: {
|
||||||
|
year: 2024,
|
||||||
|
earned: 25,
|
||||||
|
is_assumed: true,
|
||||||
|
carried_in: 0,
|
||||||
|
planned_use: 2,
|
||||||
|
left: 23,
|
||||||
|
carried_out: 5,
|
||||||
|
expiring: 0,
|
||||||
|
},
|
||||||
|
parental_leave: {
|
||||||
|
year: 2024,
|
||||||
|
sjukpenning_used: 10,
|
||||||
|
lagsta_used: 0,
|
||||||
|
sjukpenning_left: 380,
|
||||||
|
lagsta_left: 90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
combined_days_used: 100,
|
combined_days_used: 100,
|
||||||
combined_days_left: 380,
|
combined_days_left: 380,
|
||||||
combined_expiring_at_b4: 134,
|
combined_expiring_at_b4: 134,
|
||||||
|
year_totals: [{ year: 2024, pl_left: 470, vacation_left: 23 }],
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchMock.mockResolvedValue({
|
fetchMock.mockResolvedValue({
|
||||||
@@ -61,6 +86,19 @@ describe('API response mappers (snake_case → camelCase)', () => {
|
|||||||
expect(result.parentProjections[0]!.savableAfterB4).toBe(96)
|
expect(result.parentProjections[0]!.savableAfterB4).toBe(96)
|
||||||
expect(result.parentProjections[0]!.expiringAtB4).toBe(134)
|
expect(result.parentProjections[0]!.expiringAtB4).toBe(134)
|
||||||
expect(result.parentProjections[0]!.savableCapAtB4).toBe(96)
|
expect(result.parentProjections[0]!.savableCapAtB4).toBe(96)
|
||||||
|
|
||||||
|
const yearBalances = result.parentProjections[0]!.yearBalances
|
||||||
|
expect(yearBalances.length).toBe(1)
|
||||||
|
expect(yearBalances[0]!.personalDataUuid).toBe('pd1')
|
||||||
|
expect(yearBalances[0]!.childId).toBe('child1')
|
||||||
|
expect(yearBalances[0]!.year).toBe(2024)
|
||||||
|
expect(yearBalances[0]!.vacation.isAssumed).toBe(true)
|
||||||
|
expect(yearBalances[0]!.vacation.plannedUse).toBe(2)
|
||||||
|
expect(yearBalances[0]!.vacation.carriedOut).toBe(5)
|
||||||
|
expect(yearBalances[0]!.parentalLeave.sjukpenningUsed).toBe(10)
|
||||||
|
expect(yearBalances[0]!.parentalLeave.sjukpenningLeft).toBe(380)
|
||||||
|
|
||||||
|
expect(result.yearTotals).toEqual([{ year: 2024, plLeft: 470, vacationLeft: 23 }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import type {
|
|||||||
PersonalData,
|
PersonalData,
|
||||||
CalendarWindow,
|
CalendarWindow,
|
||||||
CalendarDayEntry,
|
CalendarDayEntry,
|
||||||
|
VacationYearBalance,
|
||||||
|
ParentalLeaveYearBalance,
|
||||||
|
ParentYearBalance,
|
||||||
|
YearTotals,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const BASE = '/api'
|
const BASE = '/api'
|
||||||
@@ -106,6 +110,82 @@ function toParentalLeaveFetch(raw: RawParentalLeaveFetch): ParentalLeaveFetch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawVacationYearBalance {
|
||||||
|
year: number
|
||||||
|
earned: number
|
||||||
|
is_assumed: boolean
|
||||||
|
carried_in: number
|
||||||
|
planned_use: number
|
||||||
|
left: number
|
||||||
|
carried_out: number
|
||||||
|
expiring: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVacationYearBalance(raw: RawVacationYearBalance): VacationYearBalance {
|
||||||
|
return {
|
||||||
|
year: raw.year,
|
||||||
|
earned: raw.earned,
|
||||||
|
isAssumed: raw.is_assumed,
|
||||||
|
carriedIn: raw.carried_in,
|
||||||
|
plannedUse: raw.planned_use,
|
||||||
|
left: raw.left,
|
||||||
|
carriedOut: raw.carried_out,
|
||||||
|
expiring: raw.expiring,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawParentalLeaveYearBalance {
|
||||||
|
year: number
|
||||||
|
sjukpenning_used: number
|
||||||
|
lagsta_used: number
|
||||||
|
sjukpenning_left: number
|
||||||
|
lagsta_left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toParentalLeaveYearBalance(
|
||||||
|
raw: RawParentalLeaveYearBalance,
|
||||||
|
): ParentalLeaveYearBalance {
|
||||||
|
return {
|
||||||
|
year: raw.year,
|
||||||
|
sjukpenningUsed: raw.sjukpenning_used,
|
||||||
|
lagstaUsed: raw.lagsta_used,
|
||||||
|
sjukpenningLeft: raw.sjukpenning_left,
|
||||||
|
lagstaLeft: raw.lagsta_left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawParentYearBalance {
|
||||||
|
personal_data_uuid: string
|
||||||
|
child_id: string
|
||||||
|
year: number
|
||||||
|
vacation: RawVacationYearBalance
|
||||||
|
parental_leave: RawParentalLeaveYearBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
function toParentYearBalance(raw: RawParentYearBalance): ParentYearBalance {
|
||||||
|
return {
|
||||||
|
personalDataUuid: raw.personal_data_uuid,
|
||||||
|
childId: raw.child_id,
|
||||||
|
year: raw.year,
|
||||||
|
vacation: toVacationYearBalance(raw.vacation),
|
||||||
|
parentalLeave: toParentalLeaveYearBalance(raw.parental_leave),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawYearTotals {
|
||||||
|
year: number
|
||||||
|
pl_left: number
|
||||||
|
vacation_left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toYearTotals(raw: RawYearTotals): YearTotals {
|
||||||
|
return {
|
||||||
|
year: raw.year,
|
||||||
|
plLeft: raw.pl_left,
|
||||||
|
vacationLeft: raw.vacation_left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface RawAge4Projection {
|
export interface RawAge4Projection {
|
||||||
personal_data_uuid: string
|
personal_data_uuid: string
|
||||||
child_id: string
|
child_id: string
|
||||||
@@ -118,6 +198,7 @@ export interface RawAge4Projection {
|
|||||||
savable_after_b4: number
|
savable_after_b4: number
|
||||||
expiring_at_b4: number
|
expiring_at_b4: number
|
||||||
savable_cap_at_b4: number
|
savable_cap_at_b4: number
|
||||||
|
year_balances: RawParentYearBalance[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAge4Projection(raw: RawAge4Projection): Age4Projection {
|
function toAge4Projection(raw: RawAge4Projection): Age4Projection {
|
||||||
@@ -133,6 +214,7 @@ function toAge4Projection(raw: RawAge4Projection): Age4Projection {
|
|||||||
savableAfterB4: raw.savable_after_b4,
|
savableAfterB4: raw.savable_after_b4,
|
||||||
expiringAtB4: raw.expiring_at_b4,
|
expiringAtB4: raw.expiring_at_b4,
|
||||||
savableCapAtB4: raw.savable_cap_at_b4,
|
savableCapAtB4: raw.savable_cap_at_b4,
|
||||||
|
yearBalances: raw.year_balances.map(toParentYearBalance),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +225,7 @@ export interface RawWorkspaceOverview {
|
|||||||
combined_days_used: number
|
combined_days_used: number
|
||||||
combined_days_left: number
|
combined_days_left: number
|
||||||
combined_expiring_at_b4: number
|
combined_expiring_at_b4: number
|
||||||
|
year_totals: RawYearTotals[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview {
|
function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview {
|
||||||
@@ -153,6 +236,7 @@ function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview {
|
|||||||
combinedDaysUsed: raw.combined_days_used,
|
combinedDaysUsed: raw.combined_days_used,
|
||||||
combinedDaysLeft: raw.combined_days_left,
|
combinedDaysLeft: raw.combined_days_left,
|
||||||
combinedExpiringAtB4: raw.combined_expiring_at_b4,
|
combinedExpiringAtB4: raw.combined_expiring_at_b4,
|
||||||
|
yearTotals: raw.year_totals.map(toYearTotals),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,53 @@ export interface CalendarWindow {
|
|||||||
days: CalendarDayEntry[]
|
days: CalendarDayEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per (parent, child, calendar year) balance series shown on the analytics
|
||||||
|
// overview — birth year through the year of the 4th birthday inclusive. See
|
||||||
|
// the backend's `app.services.analytics_service.ParentYearBalance` docstring
|
||||||
|
// for the vacation carry-forward (Semesterlagen, 5-year expiry) algorithm.
|
||||||
|
|
||||||
|
export interface VacationYearBalance {
|
||||||
|
year: number
|
||||||
|
/** Entitlement for this year: the entered total, or the statutory default (25). */
|
||||||
|
earned: number
|
||||||
|
/** True when no `vacation_days` row exists for this (parent, year) — `earned` is the statutory default. */
|
||||||
|
isAssumed: boolean
|
||||||
|
/** Days carried in from prior years, still valid. */
|
||||||
|
carriedIn: number
|
||||||
|
/** Committed 'vacation' planned-leave rows in this year (across all of this parent's children). */
|
||||||
|
plannedUse: number
|
||||||
|
/** max(earned + carriedIn - plannedUse, 0). */
|
||||||
|
left: number
|
||||||
|
/** Days saved forward into next year's carriedIn. */
|
||||||
|
carriedOut: number
|
||||||
|
/** Days that hit the 5-year carry-forward cap this year — LOST if not used. */
|
||||||
|
expiring: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentalLeaveYearBalance {
|
||||||
|
year: number
|
||||||
|
sjukpenningUsed: number
|
||||||
|
lagstaUsed: number
|
||||||
|
/** Running balance at this year's end (age-4 clamp applied in the 4th-birthday year). */
|
||||||
|
sjukpenningLeft: number
|
||||||
|
lagstaLeft: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentYearBalance {
|
||||||
|
personalDataUuid: string
|
||||||
|
childId: string
|
||||||
|
year: number
|
||||||
|
vacation: VacationYearBalance
|
||||||
|
parentalLeave: ParentalLeaveYearBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combined (all parents) balance for one calendar year, on the overview. */
|
||||||
|
export interface YearTotals {
|
||||||
|
year: number
|
||||||
|
plLeft: number
|
||||||
|
vacationLeft: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Age4Projection {
|
export interface Age4Projection {
|
||||||
personalDataUuid: string
|
personalDataUuid: string
|
||||||
childId: string
|
childId: string
|
||||||
@@ -236,6 +283,8 @@ export interface Age4Projection {
|
|||||||
// parents. See `resolve_age4_savable_cap` (backend). Render the per-parent
|
// parents. See `resolve_age4_savable_cap` (backend). Render the per-parent
|
||||||
// bar against this field directly; never re-derive the 48-vs-96 rule here.
|
// bar against this field directly; never re-derive the 48-vs-96 rule here.
|
||||||
savableCapAtB4: number
|
savableCapAtB4: number
|
||||||
|
// Per-calendar-year vacation + FP balance series — see `ParentYearBalance`.
|
||||||
|
yearBalances: ParentYearBalance[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceOverview {
|
export interface WorkspaceOverview {
|
||||||
@@ -245,6 +294,8 @@ export interface WorkspaceOverview {
|
|||||||
combinedDaysUsed: number
|
combinedDaysUsed: number
|
||||||
combinedDaysLeft: number
|
combinedDaysLeft: number
|
||||||
combinedExpiringAtB4: number
|
combinedExpiringAtB4: number
|
||||||
|
// Combined (all parents) per-year totals — see `YearTotals`.
|
||||||
|
yearTotals: YearTotals[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tip {
|
export interface Tip {
|
||||||
|
|||||||
Vendored
+61
-1
@@ -1,6 +1,64 @@
|
|||||||
/** Shared test fixtures — Dashboard analytics (`GET /api/analytics/overview`). */
|
/** Shared test fixtures — Dashboard analytics (`GET /api/analytics/overview`). */
|
||||||
|
|
||||||
import type { Age4Projection, WorkspaceOverview } from '@/types'
|
import type {
|
||||||
|
Age4Projection,
|
||||||
|
ParentalLeaveYearBalance,
|
||||||
|
ParentYearBalance,
|
||||||
|
VacationYearBalance,
|
||||||
|
WorkspaceOverview,
|
||||||
|
YearTotals,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export function makeVacationYearBalance(
|
||||||
|
overrides: Partial<VacationYearBalance> = {},
|
||||||
|
): VacationYearBalance {
|
||||||
|
return {
|
||||||
|
year: 2025,
|
||||||
|
earned: 25,
|
||||||
|
isAssumed: true,
|
||||||
|
carriedIn: 0,
|
||||||
|
plannedUse: 0,
|
||||||
|
left: 25,
|
||||||
|
carriedOut: 5,
|
||||||
|
expiring: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeParentalLeaveYearBalance(
|
||||||
|
overrides: Partial<ParentalLeaveYearBalance> = {},
|
||||||
|
): ParentalLeaveYearBalance {
|
||||||
|
return {
|
||||||
|
year: 2025,
|
||||||
|
sjukpenningUsed: 0,
|
||||||
|
lagstaUsed: 0,
|
||||||
|
sjukpenningLeft: 390,
|
||||||
|
lagstaLeft: 90,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeParentYearBalance(
|
||||||
|
overrides: Partial<ParentYearBalance> = {},
|
||||||
|
): ParentYearBalance {
|
||||||
|
return {
|
||||||
|
personalDataUuid: 'pd-a',
|
||||||
|
childId: 'child-1',
|
||||||
|
year: 2025,
|
||||||
|
vacation: makeVacationYearBalance(),
|
||||||
|
parentalLeave: makeParentalLeaveYearBalance(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeYearTotals(overrides: Partial<YearTotals> = {}): YearTotals {
|
||||||
|
return {
|
||||||
|
year: 2025,
|
||||||
|
plLeft: 480,
|
||||||
|
vacationLeft: 25,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function makeAge4Projection(overrides: Partial<Age4Projection> = {}): Age4Projection {
|
export function makeAge4Projection(overrides: Partial<Age4Projection> = {}): Age4Projection {
|
||||||
return {
|
return {
|
||||||
@@ -15,6 +73,7 @@ export function makeAge4Projection(overrides: Partial<Age4Projection> = {}): Age
|
|||||||
savableAfterB4: 96,
|
savableAfterB4: 96,
|
||||||
expiringAtB4: 60,
|
expiringAtB4: 60,
|
||||||
savableCapAtB4: 96,
|
savableCapAtB4: 96,
|
||||||
|
yearBalances: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +86,7 @@ export function makeWorkspaceOverview(overrides: Partial<WorkspaceOverview> = {}
|
|||||||
combinedDaysUsed: 30,
|
combinedDaysUsed: 30,
|
||||||
combinedDaysLeft: 270,
|
combinedDaysLeft: 270,
|
||||||
combinedExpiringAtB4: 100,
|
combinedExpiringAtB4: 100,
|
||||||
|
yearTotals: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user