Files
F-r-ldraledigplannerare/backend/app/utils/tip_predicates.py
T
jacobh2 cc3ced905e Link planned days to their wish, fix the age-4 cap, drop the planning goal
Three changes that together unblock treating a calendar period as one
editable object.

Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL)
so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a
day the user hand-edited, and `leave_wish.target_days` so a period drawn on a
calendar need not be a whole number of weeks. Apply-time identity still keys on
day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update
rather than a collision.

The age-4 savable ceiling was applying the per-child figure of 96 to each
parent's pool, modelling a two-parent family as able to save 192 days past the
child's fourth birthday. `resolve_age4_savable_cap` is now the single place
that decides 48 (couple with two parents) versus 96 (everyone else), and both
AnalyticsService and the allocator read it.

Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`.
The allocator's tier-3 policy collapses to one rule; `salary_type` still
branches, since that reflects what a vacation day actually costs a parent
rather than a user preference.
2026-09-15 20:28:06 +02:00

173 lines
6.5 KiB
Python

"""Predicate helpers for the tips engine.
These are pure functions (no I/O) that the tips service calls after loading
the workspace context. Each takes pre-fetched data and returns a bool or a
supporting value. Keeping them pure makes them trivial to unit-test.
"""
from __future__ import annotations
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from app.config import settings
from app.entities.child import Child
from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
from app.utils.holidays import is_non_working_day
def weekly_intensity(planned_days: list[PlannedLeaveDay], period_weeks: float) -> float:
"""Average föräldrapenning days per week over a given period.
Only sjukpenning+lagsta count; vacation days are excluded (they don't draw
from the FP pool and are not the planning knob that affects SGI).
"""
if period_weeks <= 0:
return 0.0
fp_days = [d for d in planned_days if d.day_type in ("sjukpenning", "lagsta")]
return len(fp_days) / period_weeks
def resolve_age4_savable_cap(planning_mode: str, 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.
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 so they can never disagree on which cap applies.
"""
rules = settings.rules
if planning_mode == "couple" and parent_count >= 2:
return rules.max_savable_days_per_parent_at_age_4
return rules.max_savable_days_at_age_4
def days_left_at_age_4_projection(
days_used: int,
planned_fp_before_b4: int,
total_days: int,
max_savable: int,
) -> tuple[int, int]:
"""Return (projected_left_at_b4, expiring_at_b4) without hitting the DB.
`max_savable` is the resolved cap (see `resolve_age4_savable_cap`) — the
caller decides which figure applies; this function only does the shared
arithmetic so `AnalyticsService` and `allocation_service._Ledger` can
never derive it differently.
"""
projected_left = total_days - (days_used + planned_fp_before_b4)
expiring = max(projected_left - max_savable, 0)
return projected_left, expiring
def is_child_under_age(birth_date: date, months: int) -> bool:
"""True if the child's age in months is less than `months`."""
return date.today() < birth_date + relativedelta(months=months)
def is_child_over_age(birth_date: date, years: int) -> bool:
"""True if the child has turned `years`."""
return date.today() >= birth_date + relativedelta(years=years)
def sgi_at_risk(
personal_data: PersonalData,
planned_days: list[PlannedLeaveDay],
child_birth_date: date,
period_start: date,
period_end: date,
) -> bool:
"""True if the parent risks losing SGI (< 5 FP days/week after age 1).
SGI is protected automatically in the first 12 months (§6). After that a
non-working, non-jobseeking parent must take ≥5 days/week to keep full SGI.
"""
rules = settings.rules
if not is_child_over_age(child_birth_date, 1):
return False # still in the protected first year
if personal_data.employment_status in ("employed", "jobseeking"):
return False # working/registered jobseeker is protected regardless
period_days = [
d
for d in planned_days
if d.parent_personal_data_uuid == personal_data.uuid
and period_start <= d.date <= period_end
and d.day_type in ("sjukpenning", "lagsta")
]
weeks = ((period_end - period_start).days + 1) / 7
intensity = weekly_intensity(period_days, weeks)
return intensity < rules.sgi_min_days_per_week_after_age_1
def red_day_clusters_in_range(
from_date: date, to_date: date, year: int | None = None
) -> list[tuple[date, date]]:
"""Return (start, end) pairs for clusters of ≥2 consecutive non-working days.
Uses `app.utils.holidays.is_non_working_day` — the single source of truth
for "is this day free" (calendar cache + `holidays` library fallback),
so weekends, public holidays AND de facto days (julafton, midsommarafton,
nyårsafton, ...) all count. `year` is accepted for backward compatibility
but unused: `is_non_working_day` resolves each date's own year, so a
range spanning a year boundary is handled correctly without it.
"""
del year # unused — is_non_working_day resolves per-date
clusters: list[tuple[date, date]] = []
cluster_start: date | None = None
current = from_date
while current <= to_date:
is_holiday = is_non_working_day(current)
if is_holiday:
if cluster_start is None:
cluster_start = current
else:
if cluster_start is not None and (current - cluster_start).days >= 2:
clusters.append((cluster_start, current - timedelta(days=1)))
cluster_start = None
current += timedelta(days=1)
if cluster_start is not None and (to_date - cluster_start).days >= 2:
clusters.append((cluster_start, to_date))
return clusters
def preschool_buffer_needed(child: Child, default_days_per_year: int) -> int:
"""Days that should be reserved for preschool planning-day closures.
Returns the default_days_per_year unless the child has no preschool_start_date
yet (in which case no reservation is needed).
"""
if child.preschool_start_date is None:
return 0
if date.today() < child.preschool_start_date:
return 0
return default_days_per_year
def has_vacation_near_expiry(
vacation_total: int,
vacation_saved_in: int,
threshold_remaining_days: int = 5,
) -> bool:
"""True if saved vacation days are approaching expiry (simple heuristic).
Swedish law: vacation days above 20 may be saved for up to 5 years.
This predicate flags when saved days exist — the tips service adds richer
expiry logic when it has the full vacation history per year.
"""
return vacation_saved_in > 0 and vacation_total > threshold_remaining_days