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
+48 -17
View File
@@ -7,9 +7,10 @@ The headline calculation (§6.5 ARCHITECTURE.md):
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.
Where `CAP` is 96 (per child) for a genuine single parent, or 48 (per
parent, non-poolable) as soon as the child has 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.
@@ -30,7 +31,11 @@ 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 DubbeldagError, NotFoundError
from app.utils.tip_predicates import days_left_at_age_4_projection, resolve_age4_savable_cap
from app.utils.tip_predicates import (
days_left_at_age_4_projection,
default_tier_pool_share,
resolve_age4_savable_cap,
)
@dataclass
@@ -47,10 +52,10 @@ class Age4Projection:
projected_left_at_b4: int
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.
# The cap actually applied — 96 (per child, single parent) or 48 (per
# parent, non-poolable, as soon as the child has 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
@@ -79,17 +84,37 @@ class AnalyticsService:
self._parents = personal_data_repo
self._workspace_repo = workspace_repo
async def get_parent_count(self, workspace_id: UUID) -> int:
"""Number of (non-deleted) parents on a workspace.
Shared read so callers that need a parent count (the age-4 cap here,
`PlanningService._tier_pool_left`'s default-tier-share lookup) never
query membership differently.
"""
members = await self._parents.list_by_workspace(workspace_id)
return len(members)
async def get_parent_count_for_child(self, child_id: UUID) -> int:
"""Number of parents on the workspace that owns this child.
Lets a caller that only has a `child_id` (no `workspace_id` in
scope) still resolve a parent count — see `PlanningService.
_tier_pool_left`, which only ever receives `(parent_uuid, child_id)`.
"""
child = await self._children.get_by_id(child_id)
if child is None:
raise NotFoundError(f"Child {child_id} not found")
return await self.get_parent_count(child.workspace_id)
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.
wraps `resolve_age4_savable_cap` with the DB read it needs (member
count) so callers never resolve the cap any other way. The cap is a
fact about how many parents the child has, not a workspace display
preference — see `resolve_age4_savable_cap`'s docstring.
"""
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))
return resolve_age4_savable_cap(await self.get_parent_count(workspace_id))
async def get_days_left_at_age_4(
self, personal_data_uuid: UUID, child_id: UUID, workspace_id: UUID | None = None
@@ -110,9 +135,15 @@ class AnalyticsService:
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.
# No FK data yet — assume full entitlement (0 used) for this
# parent's share, via the same shared helper `AllocationInputLoader.
# load`/`PlanningService._tier_pool_left` use, so a sole parent
# (their whole-pool share) and a partnered one (half) never
# disagree with the Plan tab's own no-snapshot default (Bug A).
days_used = 0
total_days = rules.days_per_parent
parent_count = await self.get_parent_count(child.workspace_id)
sjuk_share, lagsta_share = default_tier_pool_share(rules, parent_count)
total_days = sjuk_share + lagsta_share
else:
days_used = latest.days_used
total_days = latest.total_days