Files
F-r-ldraledigplannerare/backend/scripts/demo_plan.py
T
jacobh2 e4719cb9ae Rework balances around per-parent days left and deduce the partner from FK
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and
dubbeldagar left (plus optional reserved days) and derives the rest in
build_snapshot, shared with the scraper. The scraper also reads the
family panel and exposes a partner deduction; with two parents it is
saved as a 'deduced' snapshot (migration 002). Parent count is the single
source of entitlement: one member gets 480 days and 96 savable at age 4,
two get 240 and 48 each. Workspaces are capped at two parents.

Also lands the in-flight squash of migrations 002–011 into the initial
schema and the removal of planning_mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:27:29 +02:00

346 lines
12 KiB
Python

#!/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
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)
}
)
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,
age4_savable_cap=resolve_age4_savable_cap(parent_count=2),
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()