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
+18 -14
View File
@@ -32,36 +32,40 @@ def weekly_intensity(planned_days: list[PlannedLeaveDay], period_weeks: float) -
return len(fp_days) / period_weeks
def default_tier_pool_share(rules: LeaveRules) -> tuple[int, int]:
def default_tier_pool_share(rules: LeaveRules, parent_count: int) -> tuple[int, int]:
"""The per-parent (sjukpenning_left, lagsta_left) assumed when no FK
snapshot exists yet.
`rules.sjukpenning_days`/`lagsta_niva_days` are the WHOLE CHILD's combined
tier totals (390 + 90), split across both parents — halving them gives a
single parent's default share. Shared by `AllocationInputLoader.load` and
tier totals (390 + 90). A sole parent (`parent_count <= 1`) gets the full
pool; with a partner (`parent_count >= 2`) it's split in half per parent
(195 + 45) exactly as FK does. Shared by `AllocationInputLoader.load` and
`PlanningService._tier_pool_left` so they can never derive it differently
(see "Bug A": they once did, 390/90 in one place vs. 195/45 in the other).
"""
if parent_count <= 1:
return rules.sjukpenning_days, rules.lagsta_niva_days
return rules.sjukpenning_days // 2, rules.lagsta_niva_days // 2
def resolve_age4_savable_cap(planning_mode: str, parent_count: int) -> int:
def resolve_age4_savable_cap(parent_count: int) -> int:
"""Single source of truth for which age-4 savable-days ceiling applies.
`rules.max_savable_days_at_age_4` (96) is the per-CHILD ceiling. When a
child actually has two parents in a workspace explicitly flagged
`planning_mode == 'couple'`, each parent's own balance is capped at
`rules.max_savable_days_per_parent_at_age_4` (48) instead — 96 each would
let the family save 192 combined, double the legal figure.
`rules.max_savable_days_at_age_4` (96) is the per-CHILD ceiling, pooled
across however many parents the child legally has. The per-parent cut is
strictly **non-poolable**: each parent's own savable balance is capped at
`rules.max_savable_days_per_parent_at_age_4` (48) whenever the child has
two (or more) parents — 96 each would let the family save 192 combined,
double the legal figure. A genuine single parent keeps the full 96.
Requiring both conditions (couple mode AND >=2 parents) is a safety belt:
a workspace flagged 'couple' that only has one member so far still gets
the per-child (96) figure, since there is no second parent to divide it
with. `AnalyticsService` and `allocation_service`'s `_Ledger` both call
This is a fact about the family (how many parents the child has), not a
workspace display preference — it is purely a function of parent count.
A workspace with two real members must always get the 48/48 split, never
96/96. `AnalyticsService` and `allocation_service`'s `_Ledger` both call
this so they can never disagree on which cap applies.
"""
rules = settings.rules
if planning_mode == "couple" and parent_count >= 2:
if parent_count >= 2:
return rules.max_savable_days_per_parent_at_age_4
return rules.max_savable_days_at_age_4