#!/usr/bin/env python3 """Demo: run the pure planning-engine Allocator on a realistic family. Builds an `AllocationInput` entirely in memory (no database) using Swedish defaults — two parents on a joint 240/240 split, 25 semesterdagar/year each, a child born 2026-03-01 — plus two recurring wishes ("6 veckor sommar" and "4 veckor jul") running until the child turns 4. Runs `Allocator().propose()` (the pure half of the planning engine — see app.services.allocation_service) and prints a human-readable Swedish summary per window. Usage (from backend/): uv run python scripts/demo_plan.py """ from __future__ import annotations from dataclasses import dataclass from datetime import date from uuid import uuid4 from dateutil.relativedelta import relativedelta from app.entities.leave_wish import LeaveWish, project_window from app.services.allocation_service import ( AllocationInput, Allocator, ChildFacts, DayPool, ParentFacts, PlanProposal, VacationBudget, WindowOutcome, ) from app.utils.holidays import get_swedish_holidays from app.utils.tip_predicates import resolve_age4_savable_cap # --------------------------------------------------------------------------- # 1. Build a realistic AllocationInput, in memory, Swedish defaults. # --------------------------------------------------------------------------- CHILD_BIRTH_DATE = date(2026, 3, 1) TODAY = date.today() def _build_input() -> tuple[AllocationInput, dict]: child = ChildFacts( id=uuid4(), birth_date=CHILD_BIRTH_DATE, fourth_birthday=CHILD_BIRTH_DATE + relativedelta(years=4), preschool_start_date=CHILD_BIRTH_DATE + relativedelta(years=1, months=4), preschool_planning_days_per_year=6, ) parent_1 = ParentFacts( uuid=uuid4(), name="Alex", salary_type="monthly", employment_status="employed" ) parent_2 = ParentFacts( uuid=uuid4(), name="Sam", salary_type="monthly", employment_status="employed" ) # 390 sjukpenning-days + 90 lägstanivå-days = 480 total, split 240/240 # between the two parents (the default joint-custody split, see # app.config.LeaveRules). pools = { (parent_1.uuid, child.id): DayPool( sjukpenning_left=195, lagsta_left=45, dubbeldagar_left=60 ), (parent_2.uuid, child.id): DayPool( sjukpenning_left=195, lagsta_left=45, dubbeldagar_left=60 ), } horizon_end = child.fourth_birthday + relativedelta(years=1) vacation = { (parent_1.uuid, year): VacationBudget(total_days=25, saved_days_in=0, already_used=0) for year in range(TODAY.year, horizon_end.year + 1) } vacation.update( { (parent_2.uuid, year): VacationBudget(total_days=25, saved_days_in=0, already_used=0) for year in range(TODAY.year, horizon_end.year + 1) } ) # A repeating wish is one row per year now (see app/entities/leave_wish.py); # `LeaveWishService` does this materialisation for real callers, so this # script — which builds an `AllocationInput` by hand, with no repositories — # spells it out with the same `project_window` projection. def _series(name: str, window: tuple[int, int, int, int], weeks: int) -> list[LeaveWish]: start_month, start_day, end_month, end_day = window workspace_id = uuid4() rows: list[LeaveWish] = [] for year in range(CHILD_BIRTH_DATE.year, CHILD_BIRTH_DATE.year + 5): start, end = project_window( start_month=start_month, start_day=start_day, end_month=end_month, end_day=end_day, anchor_year=CHILD_BIRTH_DATE.year, year=year, window_mode="fixed", ) rows.append( LeaveWish( id=uuid4(), workspace_id=workspace_id, child_id=child.id, name=name, window_start_month=start.month, window_start_day=start.day, window_end_month=end.month, window_end_day=end.day, target_weeks=weeks, first_year=year, last_year=None, # until the child's 4th birthday priority=1, parent_mode="either", # lets the allocator swap between parents parent_personal_data_uuid=None, ) ) return rows # Sommar: 6 weeks. Jul: 4 weeks, wrapping into January. wishes = _series("Sommar", (6, 15, 7, 26), 6) + _series("Jul", (12, 20, 1, 16), 4) holidays_map: dict[date, str] = {} for year in range(TODAY.year, horizon_end.year + 2): holidays_map.update(get_swedish_holidays(year)) alloc_input = AllocationInput( today=TODAY, horizon_end=horizon_end, age4_savable_cap=resolve_age4_savable_cap(parent_count=2), children=[child], parents=[parent_1, parent_2], pools=pools, vacation=vacation, wishes=wishes, existing_planned=[], holidays=holidays_map, ) names = {parent_1.uuid: parent_1.name, parent_2.uuid: parent_2.name} return alloc_input, names # --------------------------------------------------------------------------- # 2. Pretty-print helpers (Swedish) # --------------------------------------------------------------------------- DAY_TYPE_LABELS = { "vacation": "semesterdagar", "sjukpenning": "FP-dagar (sjukpenningnivå)", "lagsta": "FP-dagar (lägstanivå)", "free": "fria dagar (helg/röd dag)", } @dataclass class _RenderedWindow: heading: str body_lines: list[str] def _render_window(outcome: WindowOutcome) -> _RenderedWindow: heading = ( f"{outcome.wish_name} {outcome.year} " f"({outcome.start_date.isoformat()} → {outcome.end_date.isoformat()})" ) lines: list[str] = [] lines.append( f" {outcome.weeks_wished} veckor önskade -> {outcome.weeks_funded:.1f} veckor finansierade" ) breakdown_bits = [] for key in ("free", "vacation", "sjukpenning", "lagsta"): count = outcome.cost_breakdown.get(key, 0) if count: breakdown_bits.append(f"{count} {DAY_TYPE_LABELS[key]}") if breakdown_bits: lines.append(" Dagar: " + ", ".join(breakdown_bits)) lines.append(f" Strategi: {outcome.strategy}") lines.append(f" Förklaring: {_render_window_explanation(outcome)}") if outcome.shortfall_days > 0: lines.append(f" ⚠ Underskott: {outcome.shortfall_days} dagar kunde inte finansieras") for remedy in outcome.remedies: lines.append(f" → {_render_message(remedy.message_key, remedy.data)}") else: lines.append(" ✓ Fullt finansierad") return _RenderedWindow(heading=heading, body_lines=lines) # --------------------------------------------------------------------------- # §3.5 — the engine now returns `message_key` + `data` (not pre-rendered # Swedish, see `PlanResults.tsx` / `frontend/src/i18n/sv.ts` for the real # UI-facing catalogue) so it can be shown in either locale. This script is a # developer CLI, not the UI — it only needs a Swedish rendering, so it keeps # a tiny local copy of the same templates rather than reaching into the # frontend's TypeScript i18n files. # --------------------------------------------------------------------------- _SV_TEMPLATES: dict[str, str] = { "planRemedies.reduce_preschool_reserve": ( "Minska förskolans planeringsdagar ({reserved} reserverade) frigör {reserved} dagar" ), "planRemedies.carry_vacation_forward": ( "{spare_total} semesterdagar från tidigare år kan sparas framåt" ), "planRemedies.lower_priority_wish": ( "Sänk {wish_name} från {from_weeks} till {to_weeks} veckor frigör {freed_days} dagar" ), "planRemedies.shift_window_into_free_days": ( "Flytta {wish_name} till {new_start_date} – {new_end_date} frigör {shift_gain} dagar" ), "planWarnings.days_lost_at_age_4": ( "riskerar att förlora {expiring_days} dagar vid barnets 4-årsdag " "({fourth_birthday}) om inte fler dagar tas ut innan dess" ), "planWarnings.sgi_risk": ( "riskerar SGI-skyddet veckan som börjar {week_start} " "({days_planned} dagar planerade, minst {min_days_per_week} krävs)" ), "planWarnings.vacation_expiring": ( "har {saved_days_in} sparade semesterdagar från {year} som riskerar att gå förlorade" ), "planSurplusAdvice.wish_more_leave": ( "har råd med ytterligare {weeks} veckor ledigt — lägg till en vecka på " "sommaren eller ett höstlov" ), "planSurplusAdvice.shift_mix_to_fp": ( "kan byta semesterdagar mot föräldrapenningdagar under planen" ), "planSurplusAdvice.front_load_early_years": ( "har ett stort överskott — ta ut fler dagar nu medan barnet är litet" ), "planSurplusAdvice.use_less_vacation": ( "kan ta ut färre semesterdagar ({years_text}) utan att gå under lägsta " "uttaget ({min_vacation_days_per_year} dagar/år)" ), } def _render_message(message_key: str, data: dict) -> str: template = _SV_TEMPLATES.get(message_key) if template is None: return f"[{message_key}] {data}" try: return template.format(**data) except KeyError: return f"[{message_key}] {data}" def _render_window_explanation(outcome: WindowOutcome) -> str: data = outcome.data text = ( f"{data['wish_name']} {data['year']} — {data['weeks_wished']} veckor önskade, " f"{data['weeks_funded']} veckor finansierade" ) if data.get("shortfall_days", 0): text += f" ({data['shortfall_days']} dagar saknas)" bits: list[str] = [] cost = outcome.cost_breakdown fp_days = cost.get("sjukpenning", 0) + cost.get("lagsta", 0) if cost.get("vacation", 0) > 0 and fp_days > 0: bits.append(f"du behåller lönen {cost['vacation']} dagar och sparar {fp_days} FP-dagar") elif cost.get("vacation", 0) > 0: bits.append(f"{cost['vacation']} semesterdagar") if cost.get("sjukpenning", 0) > 0: bits.append(f"{cost['sjukpenning']} föräldrapenningdagar (sjukpenningnivå)") if cost.get("lagsta", 0) > 0: bits.append(f"{cost['lagsta']} föräldrapenningdagar (lägstanivå)") if cost.get("free", 0) > 0: bits.append(f"{cost['free']} helt fria dagar (helg/röd dag)") if bits: text += ": " + ", ".join(bits) return text def _print_report(proposal: PlanProposal, parent_names: dict) -> None: print("=" * 78) print("FÖRÄLDRALEDIGHETSPLAN — förslag (ej sparat)") print(f"Barn fött: {CHILD_BIRTH_DATE.isoformat()} | Dagens datum: {TODAY.isoformat()}") print("=" * 78) print() outcomes = sorted(proposal.outcomes, key=lambda o: (o.year, o.start_date)) if not outcomes: print("Inga fönster inom planeringshorisonten (allt ligger i det förflutna).") for outcome in outcomes: rendered = _render_window(outcome) print(rendered.heading) for line in rendered.body_lines: print(line) print() total_wished_weeks = sum(o.weeks_wished for o in outcomes) total_funded_weeks = sum(o.weeks_funded for o in outcomes) total_shortfall = sum(o.shortfall_days for o in outcomes) print("-" * 78) print( f"Totalt: {total_wished_weeks} veckor önskade, {total_funded_weeks:.1f} veckor " f"finansierade över {len(outcomes)} fönster ({total_shortfall} dagar i underskott totalt)." ) print() if proposal.warnings: print("Varningar:") for warning in proposal.warnings: name = parent_names.get(warning.data.get("parent_personal_data_uuid"), "") body = _render_message(warning.message_key, warning.data) print(f" [{warning.kind}] {name} {body}".strip()) print() if proposal.surplus_advice: print("Förslag (överskott vid 4-årsdagen, §9.2):") for advice in proposal.surplus_advice: name = parent_names.get(advice.data.get("parent_personal_data_uuid"), "") body = _render_message(advice.message_key, advice.data) print(f" → [{advice.kind}] {name} {body}".strip()) print() print("Kvarvarande saldon efter planen:") for (parent_uuid, child_id), pool in proposal.balances_after.pools_after.items(): name = parent_names.get(parent_uuid, str(parent_uuid)) print( f" {name}: {pool.sjukpenning_left} sjukpenningdagar, " f"{pool.lagsta_left} lägstanivådagar, {pool.dubbeldagar_left} dubbeldagar kvar" ) print() for (parent_uuid, year), budget in sorted( proposal.balances_after.vacation_after.items(), key=lambda kv: (str(kv[0][0]), kv[0][1]) ): name = parent_names.get(parent_uuid, str(parent_uuid)) used = budget.already_used if used == 0: continue print( f" {name} {year}: {used}/{budget.total_days + budget.saved_days_in} semesterdagar använda" ) def main() -> None: alloc_input, parent_names = _build_input() proposal = Allocator().propose(alloc_input) _print_report(proposal, parent_names) if __name__ == "__main__": main()