Rework balances around per-parent days left and deduce the partner from FK

Manual entry now takes one parent's sjukpenningnivå, lägstanivå and
dubbeldagar left (plus optional reserved days) and derives the rest in
build_snapshot, shared with the scraper. The scraper also reads the
family panel and exposes a partner deduction; with two parents it is
saved as a 'deduced' snapshot (migration 002). Parent count is the single
source of entitlement: one member gets 480 days and 96 savable at age 4,
two get 240 and 48 each. Workspaces are capped at two parents.

Also lands the in-flight squash of migrations 002–011 into the initial
schema and the removal of planning_mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent 375bc64f2f
commit e4719cb9ae
71 changed files with 5705 additions and 1859 deletions
+12 -20
View File
@@ -41,7 +41,6 @@ from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.repositories.tip_dismissal_repository import TipDismissalRepository
from app.repositories.vacation_days_repository import VacationDaysRepository
from app.repositories.workspace_repository import WorkspaceRepository
from app.utils.errors import NotFoundError
from app.utils.tip_predicates import (
age4_pooling_loss,
@@ -91,10 +90,6 @@ 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.
@@ -161,7 +156,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))
savable_cap = resolve_age4_savable_cap(len(ctx.parents))
for parent in ctx.parents:
leave = ctx.leave_by_parent.get(parent.uuid)
if leave is None:
@@ -192,10 +187,14 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None:
"""Warn a couple when one parent is over the per-parent age-4 cap while
the other has unused headroom — days that could have been pooled but
won't be, because `resolve_age4_savable_cap` splits the ceiling per
parent once a workspace is in `couple` mode (see `age4_pooling_loss`).
"""Warn a family when one parent is over the per-parent age-4 cap while
another has unused headroom — days that could have been pooled but won't
be, because `resolve_age4_savable_cap` splits the ceiling per parent as
soon as the child has 2+ parents (see `age4_pooling_loss`). This fires
for any 2+-parent family — the per-parent cut (and the pooling loss it
can cause) is a legal fact about the child having two parents, purely a
function of parent count.
Advice order is the substance of this tip: taking more leave before the
birthday converts entitlement into actual time at home (strictly
@@ -207,7 +206,7 @@ async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None:
exact transfer figure can't be known, so the tip degrades to a hedged
"if you both have days left" phrasing instead of guessing a number.
"""
if ctx.workspace_planning_mode != "couple" or not ctx.children:
if len(ctx.parents) < 2 or not ctx.children:
return None
child = ctx.children[0]
@@ -215,7 +214,7 @@ async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None:
return None # first couple of years: too early to usefully act on this
fourth_bday = child.birth_date + _relativedelta(years=4)
per_parent_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents))
per_parent_cap = resolve_age4_savable_cap(len(ctx.parents))
per_child_cap = settings.rules.max_savable_days_at_age_4
projected_left_by_parent: dict[UUID, int] = {}
@@ -358,7 +357,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))
savable_cap = resolve_age4_savable_cap(len(ctx.parents))
for parent in ctx.parents:
parent_window_days = [
@@ -604,7 +603,6 @@ class TipsService:
pd_repo: PersonalDataRepository,
vacation_repo: VacationDaysRepository,
leave_wish_repo: LeaveWishRepository,
workspace_repo: WorkspaceRepository,
dismissal_repo: TipDismissalRepository,
) -> None:
self._leave = leave_repo
@@ -613,7 +611,6 @@ class TipsService:
self._parents = pd_repo
self._vacation = vacation_repo
self._leave_wishes = leave_wish_repo
self._workspace_repo = workspace_repo
self._dismissals = dismissal_repo
async def get_tips(self, workspace_id: UUID) -> list[Tip]:
@@ -725,9 +722,6 @@ class TipsService:
personal_data_uuid=pd.uuid, workspace_id=workspace_id
)
ws = await self._workspace_repo.get_by_id(workspace_id)
planning_mode = ws.planning_mode if ws else "solo"
workspace_ctx = TipsContext(
workspace_id=workspace_id,
children=[],
@@ -737,7 +731,6 @@ class TipsService:
vacation_by_parent=vacation_by_parent,
leave_wishes=leave_wishes,
today=today,
workspace_planning_mode=planning_mode,
)
child_contexts: list[TipsContext] = []
@@ -758,7 +751,6 @@ class TipsService:
vacation_by_parent=vacation_by_parent,
leave_wishes=leave_wishes,
today=today,
workspace_planning_mode=planning_mode,
)
)