269 lines
10 KiB
Python
269 lines
10 KiB
Python
"""Tips engine.
|
||
|
||
Architecture:
|
||
- `Tip` is a plain dataclass returned to the router.
|
||
- `RULE_REGISTRY` is a list of async functions; each takes a `TipsContext`
|
||
and returns a `Tip | None`. None means the rule doesn't apply right now.
|
||
- `TipsService.get_tips()` runs all rules and returns the non-None results.
|
||
|
||
All 12 rules from ARCHITECTURE.md §6.1 are registered; the stubs return None
|
||
until implemented. This lets the junior wire rules one at a time by editing the
|
||
corresponding function without touching the plumbing.
|
||
|
||
To add a new rule:
|
||
1. Write an async function `_rule_<id>(ctx: TipsContext) -> Tip | None`.
|
||
2. Append it to RULE_REGISTRY.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from dataclasses import dataclass
|
||
from datetime import date, timedelta
|
||
from typing import Callable, Coroutine, TypeAlias
|
||
from uuid import UUID
|
||
|
||
from dateutil.relativedelta import relativedelta as _relativedelta
|
||
|
||
from app.config import settings
|
||
from app.entities.break_period import BreakPeriod
|
||
from app.entities.child import Child
|
||
from app.entities.parental_leave import ParentalLeaveFetch
|
||
from app.entities.personal_data import PersonalData
|
||
from app.entities.planned_leave import PlannedLeaveDay
|
||
from app.entities.vacation_days import VacationDays
|
||
from app.repositories.break_period_repository import BreakPeriodRepository
|
||
from app.repositories.child_repository import ChildRepository
|
||
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
||
from app.repositories.personal_data_repository import PersonalDataRepository
|
||
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
||
from app.repositories.vacation_days_repository import VacationDaysRepository
|
||
from app.repositories.workspace_repository import WorkspaceRepository
|
||
from app.utils.tip_predicates import (
|
||
has_vacation_near_expiry,
|
||
is_child_over_age,
|
||
is_child_under_age,
|
||
sgi_at_risk,
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class Tip:
|
||
id: str
|
||
severity: str # 'info' | 'warning' | 'action'
|
||
message_key: str # i18n key for frontend
|
||
data: dict # context for frontend rendering (e.g. {days: 5, period: "Jul–Aug"})
|
||
|
||
|
||
@dataclass
|
||
class TipsContext:
|
||
workspace_id: UUID
|
||
workspace_planning_goal: str # 'max_time' | 'max_money' | 'balanced'
|
||
children: list[Child]
|
||
parents: list[PersonalData]
|
||
leave_by_parent: dict[UUID, ParentalLeaveFetch | None] # personal_data_uuid → latest
|
||
planned_days: list[PlannedLeaveDay] # all future planned days for workspace
|
||
vacation_by_parent: dict[UUID, list[VacationDays]]
|
||
break_periods: list[BreakPeriod]
|
||
|
||
|
||
# Type alias for a rule function.
|
||
RuleFn: TypeAlias = Callable[[TipsContext], Coroutine[None, None, Tip | None]]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Rule implementations (stub → return None until fully wired)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def _rule_use_vacation_before_expiry(ctx: TipsContext) -> Tip | None:
|
||
for parent in ctx.parents:
|
||
for vd in ctx.vacation_by_parent.get(parent.uuid, []):
|
||
if has_vacation_near_expiry(vd.total_days, vd.saved_days_in):
|
||
return Tip(
|
||
id="use_vacation_before_expiry",
|
||
severity="warning",
|
||
message_key="tips.use_vacation_before_expiry",
|
||
data={
|
||
"parent_name": parent.name,
|
||
"saved_days": vd.saved_days_in,
|
||
"year": vd.year,
|
||
},
|
||
)
|
||
return None
|
||
|
||
|
||
async def _rule_balance_between_parents(ctx: TipsContext) -> Tip | None:
|
||
# TODO: compare days_used across parents; flag imbalance > 30 days
|
||
return None
|
||
|
||
|
||
async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
|
||
# TODO: use analytics_service.get_days_left_at_age_4; fire when expiring_at_b4 > 0
|
||
return None
|
||
|
||
|
||
async def _rule_use_dubbeldagar_strategically(ctx: TipsContext) -> Tip | None:
|
||
# TODO: detect dates where both parents plan the same day without is_dubbeldag=True
|
||
return None
|
||
|
||
|
||
async def _rule_lagsta_niva_coverage(ctx: TipsContext) -> Tip | None:
|
||
# TODO: suggest lägstanivå days when sjukpenning pool is low
|
||
return None
|
||
|
||
|
||
async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
|
||
# TODO: flag when weekly_intensity >= 7 and days_left_at_b4 is low
|
||
return None
|
||
|
||
|
||
async def _rule_skip_fp_first_12_months(ctx: TipsContext) -> Tip | None:
|
||
if ctx.workspace_planning_goal != "max_time":
|
||
return None
|
||
for child in ctx.children:
|
||
if is_child_under_age(child.birth_date, months=12):
|
||
for parent in ctx.parents:
|
||
fp_planned = [
|
||
d
|
||
for d in ctx.planned_days
|
||
if d.parent_personal_data_uuid == parent.uuid
|
||
and d.day_type in ("sjukpenning", "lagsta")
|
||
and d.date < child.birth_date + _relativedelta(months=12)
|
||
]
|
||
if fp_planned:
|
||
return Tip(
|
||
id="skip_fp_first_12_months",
|
||
severity="info",
|
||
message_key="tips.skip_fp_first_12_months",
|
||
data={"parent_name": parent.name},
|
||
)
|
||
return None
|
||
|
||
|
||
async def _rule_sgi_minimum_after_age_1(ctx: TipsContext) -> Tip | None:
|
||
today = date.today()
|
||
for child in ctx.children:
|
||
if not is_child_over_age(child.birth_date, 1):
|
||
continue
|
||
period_end = today + timedelta(weeks=4)
|
||
for parent in ctx.parents:
|
||
if sgi_at_risk(parent, ctx.planned_days, child.birth_date, today, period_end):
|
||
return Tip(
|
||
id="sgi_minimum_after_age_1",
|
||
severity="warning",
|
||
message_key="tips.sgi_minimum_after_age_1",
|
||
data={
|
||
"parent_name": parent.name,
|
||
"min_days_per_week": settings.rules.sgi_min_days_per_week_after_age_1,
|
||
},
|
||
)
|
||
return None
|
||
|
||
|
||
async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None:
|
||
if ctx.workspace_planning_goal != "max_money":
|
||
return None
|
||
# TODO: detect FP-spanned weekends without vacation bridging, for monthly-salary parents
|
||
return None
|
||
|
||
|
||
async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None:
|
||
if ctx.workspace_planning_goal != "max_money":
|
||
return None
|
||
# TODO: use red_day_clusters_in_range(); flag FP overlap with clusters
|
||
return None
|
||
|
||
|
||
async def _rule_long_break_parent_swap(ctx: TipsContext) -> Tip | None:
|
||
# TODO: match break_periods to a window where one parent could FP + other vacation
|
||
return None
|
||
|
||
|
||
async def _rule_reserve_for_preschool_planning_days(ctx: TipsContext) -> Tip | None:
|
||
# TODO: check preschool_buffer_needed(); compare against current reserved plan
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Registry — order matters (earlier rules shown first)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
RULE_REGISTRY: list[RuleFn] = [
|
||
_rule_use_vacation_before_expiry,
|
||
_rule_balance_between_parents,
|
||
_rule_preserve_for_age_4,
|
||
_rule_use_dubbeldagar_strategically,
|
||
_rule_lagsta_niva_coverage,
|
||
_rule_stretch_time_at_home,
|
||
_rule_skip_fp_first_12_months,
|
||
_rule_sgi_minimum_after_age_1,
|
||
_rule_wrap_weekend_with_vacation,
|
||
_rule_wrap_red_days_with_vacation,
|
||
_rule_long_break_parent_swap,
|
||
_rule_reserve_for_preschool_planning_days,
|
||
]
|
||
|
||
|
||
class TipsService:
|
||
def __init__(
|
||
self,
|
||
leave_repo: ParentalLeaveRepository,
|
||
planned_repo: PlannedLeaveRepository,
|
||
child_repo: ChildRepository,
|
||
pd_repo: PersonalDataRepository,
|
||
vacation_repo: VacationDaysRepository,
|
||
break_repo: BreakPeriodRepository,
|
||
workspace_repo: WorkspaceRepository,
|
||
) -> None:
|
||
self._leave = leave_repo
|
||
self._planned = planned_repo
|
||
self._children = child_repo
|
||
self._parents = pd_repo
|
||
self._vacation = vacation_repo
|
||
self._breaks = break_repo
|
||
self._workspace_repo = workspace_repo
|
||
|
||
async def get_tips(self, workspace_id: UUID) -> list[Tip]:
|
||
"""Load workspace context then run all rules, returning non-None tips."""
|
||
ctx = await self._build_context(workspace_id)
|
||
results = await asyncio.gather(*(rule(ctx) for rule in RULE_REGISTRY))
|
||
return [tip for tip in results if tip is not None]
|
||
|
||
async def _build_context(self, workspace_id: UUID) -> TipsContext:
|
||
|
||
parents = await self._parents.list_by_workspace(workspace_id)
|
||
children = await self._children.list_by_workspace(workspace_id)
|
||
break_periods = await self._breaks.get_by_workspace(workspace_id)
|
||
|
||
today = date.today()
|
||
all_planned: list[PlannedLeaveDay] = []
|
||
leave_by_parent: dict[UUID, ParentalLeaveFetch | None] = {}
|
||
vacation_by_parent: dict[UUID, list[VacationDays]] = {}
|
||
|
||
if children:
|
||
child = children[0] # MVP: single child
|
||
for pd in parents:
|
||
leave_by_parent[pd.uuid] = await self._leave.get_latest_by_parent(pd.uuid, child.id)
|
||
future = await self._planned.list_by_workspace_child(
|
||
workspace_id, child.id, from_date=today
|
||
)
|
||
all_planned.extend(future)
|
||
vacation_by_parent[pd.uuid] = await self._vacation.list(
|
||
personal_data_uuid=pd.uuid, workspace_id=workspace_id
|
||
)
|
||
|
||
ws = await self._workspace_repo.get_by_id(workspace_id)
|
||
planning_goal = ws.planning_goal if ws else "balanced"
|
||
|
||
return TipsContext(
|
||
workspace_id=workspace_id,
|
||
workspace_planning_goal=planning_goal,
|
||
children=children,
|
||
parents=parents,
|
||
leave_by_parent=leave_by_parent,
|
||
planned_days=all_planned,
|
||
vacation_by_parent=vacation_by_parent,
|
||
break_periods=break_periods,
|
||
)
|