Link planned days to their wish, fix the age-4 cap, drop the planning goal
Three changes that together unblock treating a calendar period as one editable object. Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL) so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a day the user hand-edited, and `leave_wish.target_days` so a period drawn on a calendar need not be a whole number of weeks. Apply-time identity still keys on day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update rather than a collision. The age-4 savable ceiling was applying the per-child figure of 96 to each parent's pool, modelling a two-parent family as able to save 192 days past the child's fourth birthday. `resolve_age4_savable_cap` is now the single place that decides 48 (couple with two parents) versus 96 (everyone else), and both AnalyticsService and the allocator read it. Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`. The allocator's tier-3 policy collapses to one rule; `salary_type` still branches, since that reflects what a vacation day actually costs a parent rather than a user preference.
This commit is contained in:
@@ -44,9 +44,9 @@ from app.utils.tip_predicates import (
|
||||
days_left_at_age_4_projection,
|
||||
has_vacation_near_expiry,
|
||||
is_child_over_age,
|
||||
is_child_under_age,
|
||||
preschool_buffer_needed,
|
||||
red_day_clusters_in_range,
|
||||
resolve_age4_savable_cap,
|
||||
sgi_at_risk,
|
||||
weekly_intensity,
|
||||
)
|
||||
@@ -63,7 +63,6 @@ class Tip:
|
||||
@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
|
||||
@@ -71,6 +70,10 @@ class TipsContext:
|
||||
vacation_by_parent: dict[UUID, list[VacationDays]]
|
||||
leave_wishes: list[LeaveWish]
|
||||
today: date # injected for deterministic testing
|
||||
# 'solo' | 'couple' — see `app.utils.tip_predicates.resolve_age4_savable_cap`.
|
||||
# Defaulted for backward compatibility with call sites/tests that don't
|
||||
# care about the age-4 cap split.
|
||||
workspace_planning_mode: str = "solo"
|
||||
|
||||
|
||||
# Type alias for a rule function.
|
||||
@@ -131,6 +134,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
|
||||
return None
|
||||
child = ctx.children[0]
|
||||
fourth_bday = child.birth_date + _relativedelta(years=4)
|
||||
savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents))
|
||||
for parent in ctx.parents:
|
||||
leave = ctx.leave_by_parent.get(parent.uuid)
|
||||
if leave is None:
|
||||
@@ -143,7 +147,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
|
||||
and d.date < fourth_bday
|
||||
)
|
||||
_, expiring_at_b4 = days_left_at_age_4_projection(
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days, savable_cap
|
||||
)
|
||||
if expiring_at_b4 > 0:
|
||||
return Tip(
|
||||
@@ -216,6 +220,7 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
|
||||
fourth_bday = child.birth_date + _relativedelta(years=4)
|
||||
today = ctx.today
|
||||
window_end = today + timedelta(weeks=4)
|
||||
savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents))
|
||||
|
||||
for parent in ctx.parents:
|
||||
parent_window_days = [
|
||||
@@ -237,7 +242,7 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
|
||||
and d.date < fourth_bday
|
||||
)
|
||||
projected_left, _ = days_left_at_age_4_projection(
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days, savable_cap
|
||||
)
|
||||
if projected_left < 60:
|
||||
return Tip(
|
||||
@@ -253,29 +258,6 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
|
||||
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 = ctx.today
|
||||
for child in ctx.children:
|
||||
@@ -297,8 +279,6 @@ async def _rule_sgi_minimum_after_age_1(ctx: TipsContext) -> Tip | None:
|
||||
|
||||
|
||||
async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None:
|
||||
if ctx.workspace_planning_goal != "max_money":
|
||||
return None
|
||||
today = ctx.today
|
||||
look_ahead = today + timedelta(weeks=8)
|
||||
for parent in ctx.parents:
|
||||
@@ -329,8 +309,6 @@ async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None:
|
||||
|
||||
|
||||
async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None:
|
||||
if ctx.workspace_planning_goal != "max_money":
|
||||
return None
|
||||
today = ctx.today
|
||||
look_ahead = today + timedelta(weeks=12)
|
||||
clusters = red_day_clusters_in_range(today, look_ahead)
|
||||
@@ -455,7 +433,6 @@ RULE_REGISTRY: list[RuleFn] = [
|
||||
_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,
|
||||
@@ -513,11 +490,10 @@ class TipsService:
|
||||
)
|
||||
|
||||
ws = await self._workspace_repo.get_by_id(workspace_id)
|
||||
planning_goal = ws.planning_goal if ws else "balanced"
|
||||
planning_mode = ws.planning_mode if ws else "solo"
|
||||
|
||||
return TipsContext(
|
||||
workspace_id=workspace_id,
|
||||
workspace_planning_goal=planning_goal,
|
||||
children=children,
|
||||
parents=parents,
|
||||
leave_by_parent=leave_by_parent,
|
||||
@@ -525,4 +501,5 @@ class TipsService:
|
||||
vacation_by_parent=vacation_by_parent,
|
||||
leave_wishes=leave_wishes,
|
||||
today=today,
|
||||
workspace_planning_mode=planning_mode,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user