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:
@@ -4,8 +4,12 @@ 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
|
||||
savable_after_B4 = min(projected_left_at_B4, CAP) # age-4 ceiling
|
||||
expiring_at_B4 = max(projected_left_at_B4 - CAP, 0) # LOST days
|
||||
|
||||
Where `CAP` is 96 (per child) normally, or 48 (per parent) for a `couple`
|
||||
workspace with two parents — see `app.utils.tip_predicates.
|
||||
resolve_age4_savable_cap`, the single function that decides which applies.
|
||||
|
||||
All rule constants come from config.LeaveRules — no magic numbers.
|
||||
dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic.
|
||||
@@ -24,8 +28,9 @@ 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.workspace_repository import WorkspaceRepository
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.tip_predicates import days_left_at_age_4_projection
|
||||
from app.utils.tip_predicates import days_left_at_age_4_projection, resolve_age4_savable_cap
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,8 +45,13 @@ class Age4Projection:
|
||||
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
|
||||
savable_after_b4: int # min(projected_left, savable_cap_at_b4)
|
||||
expiring_at_b4: int # max(projected_left - savable_cap_at_b4, 0) — LOST if not used
|
||||
# The cap actually applied — 96 (per child) or 48 (per parent, couple
|
||||
# workspace with 2 parents). See `resolve_age4_savable_cap`. Surfaced so
|
||||
# the frontend can render "X kvar / Y sparbara" without re-deriving the
|
||||
# rule itself.
|
||||
savable_cap_at_b4: int
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -61,11 +71,25 @@ class AnalyticsService:
|
||||
planned_leave_repo: PlannedLeaveRepository,
|
||||
child_repo: ChildRepository,
|
||||
personal_data_repo: PersonalDataRepository,
|
||||
workspace_repo: WorkspaceRepository,
|
||||
) -> None:
|
||||
self._leave = parental_leave_repo
|
||||
self._planned = planned_leave_repo
|
||||
self._children = child_repo
|
||||
self._parents = personal_data_repo
|
||||
self._workspace_repo = workspace_repo
|
||||
|
||||
async def _resolve_savable_cap(self, workspace_id: UUID) -> int:
|
||||
"""The single call site AnalyticsService uses for the age-4 cap —
|
||||
|
||||
wraps `resolve_age4_savable_cap` with the DB reads it needs
|
||||
(workspace planning_mode + member count) so callers never resolve
|
||||
the cap any other way.
|
||||
"""
|
||||
workspace = await self._workspace_repo.get_by_id(workspace_id)
|
||||
planning_mode = workspace.planning_mode if workspace is not None else "solo"
|
||||
members = await self._parents.list_by_workspace(workspace_id)
|
||||
return resolve_age4_savable_cap(planning_mode, len(members))
|
||||
|
||||
async def get_days_left_at_age_4(
|
||||
self, personal_data_uuid: UUID, child_id: UUID
|
||||
@@ -108,6 +132,8 @@ class AnalyticsService:
|
||||
)
|
||||
planned_fp_before_b4 = sjukpenning_before_b4 + lagsta_before_b4
|
||||
|
||||
savable_cap = await self._resolve_savable_cap(child.workspace_id)
|
||||
|
||||
# Shared with the plan-proposal side (`allocation_service._Ledger.
|
||||
# build_age4_projections`) so the two surfaces can never re-derive
|
||||
# this arithmetic differently — see `days_left_at_age_4_projection`.
|
||||
@@ -115,8 +141,9 @@ class AnalyticsService:
|
||||
days_used=days_used,
|
||||
planned_fp_before_b4=planned_fp_before_b4,
|
||||
total_days=total_days,
|
||||
max_savable=savable_cap,
|
||||
)
|
||||
savable = min(projected_left, rules.max_savable_days_at_age_4)
|
||||
savable = min(projected_left, savable_cap)
|
||||
|
||||
return Age4Projection(
|
||||
personal_data_uuid=personal_data_uuid,
|
||||
@@ -129,6 +156,7 @@ class AnalyticsService:
|
||||
projected_left_at_b4=projected_left,
|
||||
savable_after_b4=savable,
|
||||
expiring_at_b4=expiring,
|
||||
savable_cap_at_b4=savable_cap,
|
||||
)
|
||||
|
||||
async def get_overview(self, workspace_id: UUID, child_id: UUID) -> WorkspaceOverview:
|
||||
|
||||
Reference in New Issue
Block a user