"""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