#!/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 from app.services.allocation_service import ( AllocationInput, Allocator, ChildFacts, DayPool, ParentFacts, PlanProposal, VacationBudget, WindowOutcome, ) from app.utils.holidays import get_swedish_holidays # --------------------------------------------------------------------------- # 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) } ) summer_wish = LeaveWish( id=uuid4(), workspace_id=uuid4(), child_id=child.id, name="Sommar", window_start_month=6, window_start_day=15, window_end_month=7, window_end_day=26, # 6 weeks target_weeks=6, recurrence="yearly", first_year=CHILD_BIRTH_DATE.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, ) christmas_wish = LeaveWish( id=uuid4(), workspace_id=summer_wish.workspace_id, child_id=child.id, name="Jul", window_start_month=12, window_start_day=20, window_end_month=1, window_end_day=16, # wraps into January; 28 days = 4 weeks target_weeks=4, recurrence="yearly", first_year=CHILD_BIRTH_DATE.year, last_year=None, priority=1, parent_mode="either", parent_personal_data_uuid=None, ) 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, planning_mode="couple", children=[child], parents=[parent_1, parent_2], pools=pools, vacation=vacation, wishes=[summer_wish, christmas_wish], 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} närmare en helg 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()