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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user