"""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 import holidays 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 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 days_left_at_age_4_projection( days_used: int, planned_fp_before_b4: int, total_days: int, ) -> tuple[int, int]: """Return (projected_left_at_b4, expiring_at_b4) without hitting the DB. Delegates the full DB-backed version to AnalyticsService. Use this in tests or when the numbers are already loaded. """ rules = settings.rules projected_left = total_days - (days_used + planned_fp_before_b4) expiring = max(projected_left - rules.max_savable_days_at_age_4, 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 public holidays. Uses the `holidays` library (Sweden locale). A "cluster" is any stretch where consecutive days are either weekends or public holidays. Useful for the red-day wrapping tip. """ target_year = year or from_date.year se_holidays = holidays.Sweden(years=target_year) clusters: list[tuple[date, date]] = [] cluster_start: date | None = None current = from_date while current <= to_date: is_holiday = current in se_holidays or current.weekday() >= 5 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