diff --git a/backend/app/config.py b/backend/app/config.py index c281486..1c0f658 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,23 +22,15 @@ class LeaveRules(BaseModel): model_config = ConfigDict(frozen=True) - total_days_per_child: int = 480 sjukpenning_days: int = 390 # income-based tier lagsta_niva_days: int = 90 # flat low-rate tier - days_per_parent: int = 240 # default joint-custody split reserved_days_per_parent: int = 90 # non-transferable ("reserverade dagar") max_dubbeldagar: int = 60 # both parents, same child-day dubbeldagar_months_limit: int = 15 # only within the child's first 15 months max_savable_days_at_age_4: int = 96 # age-4 ceiling — per CHILD (pooled across parents) - # Age-4 ceiling — per PARENT. The 96-day figure above is a per-child pool; - # when a child has two parents, each parent's own savable balance is - # capped at half of that (48), not 96 each (which would let a two-parent - # family save 192 combined). `app.utils.tip_predicates.resolve_age4_savable_cap` - # is the single place that decides which of the two applies. - max_savable_days_per_parent_at_age_4: int = 48 hard_expiry_age_years: int = 12 # A couple's per-parent age-4 pooling-loss tip (see @@ -66,10 +58,34 @@ class LeaveRules(BaseModel): # allocator actually enforces (PLANNING_ENGINE_SPEC.md §9.2). min_vacation_days_per_year: int = 20 + @property + def total_days_per_child(self) -> int: + return self.sjukpenning_days + self.lagsta_niva_days + + @property + def days_per_parent(self) -> int: + """Default joint-custody split: half of each tier, summed. + + Derived rather than stored so it can never desync from + `sjukpenning_days`/`lagsta_niva_days` if a rule year changes either. + """ + return self.sjukpenning_days // 2 + self.lagsta_niva_days // 2 + @property def transferable_days_per_parent(self) -> int: return self.days_per_parent - self.reserved_days_per_parent + @property + def max_savable_days_per_parent_at_age_4(self) -> int: + """Age-4 ceiling — per PARENT. `max_savable_days_at_age_4` (96) is a + per-child pool; when a child has two parents, each parent's own + savable balance is capped at half of that (48), not 96 each (which + would let a two-parent family save 192 combined). + `app.utils.tip_predicates.resolve_age4_savable_cap` is the single + place that decides which of the two applies. + """ + return self.max_savable_days_at_age_4 // 2 + class Settings(BaseSettings): model_config = SettingsConfigDict( diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index cd749a5..6309a4a 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -44,6 +44,7 @@ from app.utils.holidays import ( ) from app.utils.tip_predicates import ( days_left_at_age_4_projection, + default_tier_pool_share, has_vacation_near_expiry, resolve_age4_savable_cap, ) @@ -1162,20 +1163,31 @@ class _Ledger: if shortfall_days <= 0: return [] remedies: list[ShortfallRemedy] = [] + remedies.extend(self._reduce_preschool_reserve_remedy(wish)) + remedies.extend(self._carry_vacation_forward_remedy(wish, w)) + remedies.extend(self._lower_priority_wish_remedy(wish)) + remedies.extend(self._shift_window_into_free_days_remedy(wish, w)) + return remedies + def _reduce_preschool_reserve_remedy(self, wish: LeaveWish) -> list[ShortfallRemedy]: remedy_child = self._resolve_wish_child(wish) - if remedy_child is not None: - reserved = self._preschool_reserved.get(remedy_child.id, 0) - if reserved > 0: - remedies.append( - ShortfallRemedy( - kind=REMEDY_REDUCE_PRESCHOOL_RESERVE, - message_key=f"planRemedies.{REMEDY_REDUCE_PRESCHOOL_RESERVE}", - data={"reserved": reserved}, - days_freed=reserved, - ) - ) + if remedy_child is None: + return [] + reserved = self._preschool_reserved.get(remedy_child.id, 0) + if reserved <= 0: + return [] + return [ + ShortfallRemedy( + kind=REMEDY_REDUCE_PRESCHOOL_RESERVE, + message_key=f"planRemedies.{REMEDY_REDUCE_PRESCHOOL_RESERVE}", + data={"reserved": reserved}, + days_freed=reserved, + ) + ] + def _carry_vacation_forward_remedy( + self, wish: LeaveWish, w: _WishWindow + ) -> list[ShortfallRemedy]: responsible = self._resolve_parents(wish) # §9.2 Task 1 consequence #2: only days *above* the parent's # semesterlagen minimum for that year may be carried forward — the @@ -1189,62 +1201,71 @@ class _Ledger: min_days = parent.min_vacation_days_per_year if parent else 0 bankable_ceiling = max(budget.total_days - min_days, 0) spare_total += min(max(budget.available, 0), bankable_ceiling) - if spare_total > 0: - remedies.append( - ShortfallRemedy( - kind=REMEDY_CARRY_VACATION_FORWARD, - message_key=f"planRemedies.{REMEDY_CARRY_VACATION_FORWARD}", - data={"spare_total": spare_total}, - days_freed=spare_total, - ) + if spare_total <= 0: + return [] + return [ + ShortfallRemedy( + kind=REMEDY_CARRY_VACATION_FORWARD, + message_key=f"planRemedies.{REMEDY_CARRY_VACATION_FORWARD}", + data={"spare_total": spare_total}, + days_freed=spare_total, ) + ] - if wish.child_id is not None: - siblings = [ - other - for other in self._input.wishes - if other.id != wish.id - and other.child_id == wish.child_id - and other.priority > wish.priority - ] - if siblings: - other = min(siblings, key=lambda o: o.priority) - # A whole-week target can only be halved to a genuinely - # smaller whole week count when it's at least 2 weeks to - # begin with (`target_weeks` has a domain floor of 1 — see - # `LeaveWishCreate.target_weeks`). At exactly 1 week, - # `target_weeks // 2 == 0`, which used to get clamped back - # up to 1, producing a nonsensical "from 1 to 1 weeks" - # no-op remedy. Suppress the suggestion entirely rather - # than claim a change that isn't one — the planner works in - # whole days/weeks, so there's no fractional week to offer - # instead. - half_weeks = other.target_weeks // 2 - if half_weeks >= 1: - other_start, other_end = other.window_for_year(other.first_year) - other_dates = _dates_in(other_start, other_end) - spendable = sum( - 1 - for d in other_dates - if not is_free_day(d, self._input.holidays, self._input.day_overrides) - ) - freed_estimate = round(spendable / 2) - if freed_estimate > 0: - remedies.append( - ShortfallRemedy( - kind=REMEDY_LOWER_PRIORITY_WISH, - message_key=f"planRemedies.{REMEDY_LOWER_PRIORITY_WISH}", - data={ - "wish_name": other.name, - "wish_name_message_key": other.name_message_key, - "from_weeks": other.target_weeks, - "to_weeks": half_weeks, - "freed_days": freed_estimate, - }, - days_freed=freed_estimate, - ) - ) + def _lower_priority_wish_remedy(self, wish: LeaveWish) -> list[ShortfallRemedy]: + if wish.child_id is None: + return [] + siblings = [ + other + for other in self._input.wishes + if other.id != wish.id + and other.child_id == wish.child_id + and other.priority > wish.priority + ] + if not siblings: + return [] + other = min(siblings, key=lambda o: o.priority) + # A whole-week target can only be halved to a genuinely + # smaller whole week count when it's at least 2 weeks to + # begin with (`target_weeks` has a domain floor of 1 — see + # `LeaveWishCreate.target_weeks`). At exactly 1 week, + # `target_weeks // 2 == 0`, which used to get clamped back + # up to 1, producing a nonsensical "from 1 to 1 weeks" + # no-op remedy. Suppress the suggestion entirely rather + # than claim a change that isn't one — the planner works in + # whole days/weeks, so there's no fractional week to offer + # instead. + half_weeks = other.target_weeks // 2 + if half_weeks < 1: + return [] + other_start, other_end = other.window_for_year(other.first_year) + other_dates = _dates_in(other_start, other_end) + spendable = sum( + 1 + for d in other_dates + if not is_free_day(d, self._input.holidays, self._input.day_overrides) + ) + freed_estimate = round(spendable / 2) + if freed_estimate <= 0: + return [] + return [ + ShortfallRemedy( + kind=REMEDY_LOWER_PRIORITY_WISH, + message_key=f"planRemedies.{REMEDY_LOWER_PRIORITY_WISH}", + data={ + "wish_name": other.name, + "wish_name_message_key": other.name_message_key, + "from_weeks": other.target_weeks, + "to_weeks": half_weeks, + "freed_days": freed_estimate, + }, + days_freed=freed_estimate, + ) + ] + def _shift_window_into_free_days_remedy( + self, wish: LeaveWish, w: _WishWindow + ) -> list[ShortfallRemedy]: shift_gain = 0 probe = w.start - timedelta(days=1) for _ in range(3): @@ -1260,21 +1281,20 @@ class _Ledger: probe += timedelta(days=1) else: break - if shift_gain > 0: - remedies.append( - ShortfallRemedy( - kind=REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS, - message_key=f"planRemedies.{REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS}", - data={ - "wish_name": wish.name, - "wish_name_message_key": wish.name_message_key, - "shift_gain": shift_gain, - }, - days_freed=shift_gain, - ) + if shift_gain <= 0: + return [] + return [ + ShortfallRemedy( + kind=REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS, + message_key=f"planRemedies.{REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS}", + data={ + "wish_name": wish.name, + "wish_name_message_key": wish.name_message_key, + "shift_gain": shift_gain, + }, + days_freed=shift_gain, ) - - return remedies + ] # -- flexible ("när det passar bäst") wish resolution (§10.2) ---------- @@ -2032,16 +2052,12 @@ class AllocationInputLoader: lagsta_left = tier.lagsta_niva_left dubbel_left = latest.dubbeldagar_left else: - # No FK snapshot yet: `rules.sjukpenning_days`/`lagsta_niva_days` - # are the WHOLE CHILD's combined tier totals (390 + 90, split - # across both parents) — assuming that full amount for a single - # parent double-counts the pool. Halve it to get this parent's - # default share, matching AnalyticsService's no-snapshot - # fallback (`rules.days_per_parent` == 195 + 45 == 240) and the - # manual-entry convention (see scripts/seed_demo.py, and - # `fk_scraper.py`'s "= rules.lagsta_niva_days/2" comment). - sjuk_left = rules.sjukpenning_days // 2 - lagsta_left = rules.lagsta_niva_days // 2 + # No FK snapshot yet: default this parent's share via the + # shared helper (see `default_tier_pool_share` docstring — + # matches the manual-entry convention in + # scripts/seed_demo.py and `fk_scraper.py`'s + # "= rules.lagsta_niva_days/2" comment). + sjuk_left, lagsta_left = default_tier_pool_share(rules) dubbel_left = rules.max_dubbeldagar # Net out days already committed locally (planned_leave_day) diff --git a/backend/app/services/planning_service.py b/backend/app/services/planning_service.py index 1036e5e..943e5a7 100644 --- a/backend/app/services/planning_service.py +++ b/backend/app/services/planning_service.py @@ -14,6 +14,7 @@ from app.repositories.vacation_days_repository import VacationDaysRepository from app.services.allocation_service import PlanningEngineService, PlanProposal from app.services.analytics_service import AnalyticsService from app.utils.errors import DubbeldagError, NotFoundError, ValidationError +from app.utils.tip_predicates import default_tier_pool_share # Day types that can actually be persisted as a `planned_leave_day` row. A # proposal may also contain 'free' entries (automatic weekend/red-day @@ -792,13 +793,11 @@ class PlanningService: sjuk_total_left = latest.tier_data.sjukpenning_left lagsta_total_left = latest.tier_data.lagsta_niva_left else: - # `rules.sjukpenning_days`/`lagsta_niva_days` are the WHOLE - # CHILD's combined tier totals across both parents — halve them - # for this parent's default share, matching - # `AllocationInputLoader.load`'s no-snapshot fallback (Bug A - # fix: the two used to disagree, 390/90 vs. 195/45). - sjuk_total_left = rules.sjukpenning_days // 2 - lagsta_total_left = rules.lagsta_niva_days // 2 + # No FK snapshot yet: default this parent's share via the shared + # helper — see `default_tier_pool_share` docstring (Bug A fix: + # this and `AllocationInputLoader.load` used to disagree, 390/90 + # vs. 195/45). + sjuk_total_left, lagsta_total_left = default_tier_pool_share(rules) committed_sjuk = await self._repo.count_by_parent_type(parent_uuid, child_id, "sjukpenning") committed_lagsta = await self._repo.count_by_parent_type(parent_uuid, child_id, "lagsta")