Initial commit
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"""Analytics service: days-left-at-age-4 and planned-vs-actual reconciliation.
|
||||
|
||||
The headline calculation (§6.5 ARCHITECTURE.md):
|
||||
|
||||
projected_used_by_B4 = days_used + planned_days(date < B4)
|
||||
projected_left_at_B4 = total_days - projected_used_by_B4
|
||||
savable_after_B4 = min(projected_left_at_B4, 96) # age-4 ceiling
|
||||
expiring_at_B4 = max(projected_left_at_B4 - 96, 0) # LOST days
|
||||
|
||||
All rule constants come from config.LeaveRules — no magic numbers.
|
||||
dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from app.config import settings
|
||||
from app.repositories.child_repository import ChildRepository
|
||||
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
||||
from app.repositories.personal_data_repository import PersonalDataRepository
|
||||
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
||||
from app.utils.errors import NotFoundError
|
||||
|
||||
|
||||
@dataclass
|
||||
class Age4Projection:
|
||||
"""Per-parent projection of days remaining at the child's 4th birthday."""
|
||||
|
||||
personal_data_uuid: UUID
|
||||
child_id: UUID
|
||||
birth_date: date
|
||||
fourth_birthday: date
|
||||
total_days: int
|
||||
days_used: int # from latest FK snapshot
|
||||
planned_days_before_b4: int # from calendar
|
||||
projected_left_at_b4: int
|
||||
savable_after_b4: int # min(projected_left, 96)
|
||||
expiring_at_b4: int # max(projected_left - 96, 0) — LOST if not used
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspaceOverview:
|
||||
workspace_id: UUID
|
||||
child_id: UUID
|
||||
parent_projections: list[Age4Projection]
|
||||
combined_days_used: int
|
||||
combined_days_left: int
|
||||
combined_expiring_at_b4: int
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
def __init__(
|
||||
self,
|
||||
parental_leave_repo: ParentalLeaveRepository,
|
||||
planned_leave_repo: PlannedLeaveRepository,
|
||||
child_repo: ChildRepository,
|
||||
personal_data_repo: PersonalDataRepository,
|
||||
) -> None:
|
||||
self._leave = parental_leave_repo
|
||||
self._planned = planned_leave_repo
|
||||
self._children = child_repo
|
||||
self._parents = personal_data_repo
|
||||
|
||||
async def get_days_left_at_age_4(
|
||||
self, personal_data_uuid: UUID, child_id: UUID
|
||||
) -> Age4Projection:
|
||||
"""Compute the headline projection for a single parent+child pair."""
|
||||
rules = settings.rules
|
||||
|
||||
child = await self._children.get_by_id(child_id)
|
||||
if child is None:
|
||||
raise NotFoundError(f"Child {child_id} not found")
|
||||
|
||||
# Compute the 4th birthday with leap-year-safe arithmetic.
|
||||
b4 = child.birth_date + relativedelta(years=4)
|
||||
|
||||
latest = await self._leave.get_latest_by_parent(personal_data_uuid, child_id)
|
||||
if latest is None:
|
||||
# No FK data yet — assume full entitlement with 0 used.
|
||||
days_used = 0
|
||||
total_days = rules.days_per_parent
|
||||
else:
|
||||
days_used = latest.days_used
|
||||
total_days = latest.total_days
|
||||
|
||||
# Count föräldrapenning planned days that fall before the 4th birthday.
|
||||
# Vacation days do NOT count against the FP pool.
|
||||
today = date.today()
|
||||
sjukpenning_before_b4 = await self._planned.count_by_parent_type(
|
||||
personal_data_uuid,
|
||||
child_id,
|
||||
"sjukpenning",
|
||||
from_date=today,
|
||||
to_date=b4,
|
||||
)
|
||||
lagsta_before_b4 = await self._planned.count_by_parent_type(
|
||||
personal_data_uuid,
|
||||
child_id,
|
||||
"lagsta",
|
||||
from_date=today,
|
||||
to_date=b4,
|
||||
)
|
||||
planned_fp_before_b4 = sjukpenning_before_b4 + lagsta_before_b4
|
||||
|
||||
projected_used = days_used + planned_fp_before_b4
|
||||
projected_left = total_days - projected_used
|
||||
savable = min(projected_left, rules.max_savable_days_at_age_4)
|
||||
expiring = max(projected_left - rules.max_savable_days_at_age_4, 0)
|
||||
|
||||
return Age4Projection(
|
||||
personal_data_uuid=personal_data_uuid,
|
||||
child_id=child_id,
|
||||
birth_date=child.birth_date,
|
||||
fourth_birthday=b4,
|
||||
total_days=total_days,
|
||||
days_used=days_used,
|
||||
planned_days_before_b4=planned_fp_before_b4,
|
||||
projected_left_at_b4=projected_left,
|
||||
savable_after_b4=savable,
|
||||
expiring_at_b4=expiring,
|
||||
)
|
||||
|
||||
async def get_overview(self, workspace_id: UUID, child_id: UUID) -> WorkspaceOverview:
|
||||
"""Compute projections for all parents in a workspace for one child."""
|
||||
members = await self._parents.list_by_workspace(workspace_id)
|
||||
if not members:
|
||||
raise NotFoundError(f"No parents found in workspace {workspace_id}")
|
||||
|
||||
projections = []
|
||||
for pd in members:
|
||||
proj = await self.get_days_left_at_age_4(pd.uuid, child_id)
|
||||
projections.append(proj)
|
||||
|
||||
combined_used = sum(p.days_used + p.planned_days_before_b4 for p in projections)
|
||||
combined_left = sum(p.projected_left_at_b4 for p in projections)
|
||||
combined_expiring = sum(p.expiring_at_b4 for p in projections)
|
||||
|
||||
return WorkspaceOverview(
|
||||
workspace_id=workspace_id,
|
||||
child_id=child_id,
|
||||
parent_projections=projections,
|
||||
combined_days_used=combined_used,
|
||||
combined_days_left=combined_left,
|
||||
combined_expiring_at_b4=combined_expiring,
|
||||
)
|
||||
|
||||
async def validate_dubbeldagar(self, child_id: UUID, new_date: date) -> None:
|
||||
"""Raise DubbeldagError if adding a dubbeldagar day would break rules.
|
||||
|
||||
Called from the planning service before persisting a new day.
|
||||
"""
|
||||
from app.utils.errors import DubbeldagError
|
||||
|
||||
child = await self._children.get_by_id(child_id)
|
||||
if child is None:
|
||||
raise NotFoundError(f"Child {child_id} not found")
|
||||
|
||||
# Window: child's birth_date ... birth_date + 15 months
|
||||
window_end = child.birth_date + relativedelta(
|
||||
months=settings.rules.dubbeldagar_months_limit
|
||||
)
|
||||
if new_date > window_end:
|
||||
raise DubbeldagError(
|
||||
f"Dubbeldagar only allowed within the child's first "
|
||||
f"{settings.rules.dubbeldagar_months_limit} months "
|
||||
f"(deadline {window_end})"
|
||||
)
|
||||
|
||||
# Quota: count already-planned dubbeldagar from child's birth_date onward.
|
||||
already_planned = await self._planned.count_dubbeldagar(
|
||||
child_id, from_date=child.birth_date
|
||||
)
|
||||
if already_planned >= settings.rules.max_dubbeldagar:
|
||||
raise DubbeldagError(
|
||||
f"Dubbeldagar quota ({settings.rules.max_dubbeldagar} days) already reached"
|
||||
)
|
||||
Reference in New Issue
Block a user