Link planned days to their wish, fix the age-4 cap, drop the planning goal

Three changes that together unblock treating a calendar period as one
editable object.

Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL)
so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a
day the user hand-edited, and `leave_wish.target_days` so a period drawn on a
calendar need not be a whole number of weeks. Apply-time identity still keys on
day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update
rather than a collision.

The age-4 savable ceiling was applying the per-child figure of 96 to each
parent's pool, modelling a two-parent family as able to save 192 days past the
child's fourth birthday. `resolve_age4_savable_cap` is now the single place
that decides 48 (couple with two parents) versus 96 (everyone else), and both
AnalyticsService and the allocator read it.

Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`.
The allocator's tier-3 policy collapses to one rule; `salary_type` still
branches, since that reflects what a vacation day actually costs a parent
rather than a user preference.
This commit is contained in:
2026-09-15 20:28:06 +02:00
parent 7f516f2cab
commit cc3ced905e
59 changed files with 1427 additions and 740 deletions
@@ -10,14 +10,40 @@ from uuid import uuid4
import pytest
from app.entities.child import Child
from app.entities.leave_wish import LeaveWish
from app.entities.personal_data import PersonalData
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.services.planning_service import PlanningService
from app.utils.errors import NotFoundError, ValidationError
from tests.conftest import SeededIds
def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish:
base = dict(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
name="Test wish",
window_start_month=6,
window_start_day=1,
window_end_month=6,
window_end_day=30,
target_weeks=1,
recurrence="once",
first_year=2026,
last_year=None,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
base.update(overrides)
return LeaveWish(**base)
class TestPlanningService:
"""Test PlanningService business logic."""
@@ -39,6 +65,49 @@ class TestPlanningService:
assert result.day_type == "vacation"
assert result.is_dubbeldag is False
async def test_add_planned_day_with_wish_id_and_is_pinned(
self,
planning_service: PlanningService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Round-trips `wish_id`/`is_pinned` through the repository (create +
re-fetch), not just the in-memory object the create call returns."""
wish = await leave_wish_repo.create(
_make_wish(seeded_ids.workspace_id, seeded_ids.child_id)
)
result = await planning_service.add_planned_day(
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date(2026, 6, 15),
day_type="vacation",
wish_id=wish.id,
is_pinned=True,
)
assert result.wish_id == wish.id
assert result.is_pinned is True
fetched = await planning_service.get(result.id)
assert fetched.wish_id == wish.id
assert fetched.is_pinned is True
async def test_add_planned_day_without_wish_id_defaults_to_none(
self,
planning_service: PlanningService,
seeded_ids: SeededIds,
) -> None:
result = await planning_service.add_planned_day(
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date(2026, 6, 15),
day_type="vacation",
)
assert result.wish_id is None
assert result.is_pinned is False
async def test_add_double_booking_raises(
self,
planning_service: PlanningService,
@@ -339,6 +408,36 @@ class TestPlanningService:
assert updated.day_type == "sjukpenning"
assert updated.is_dubbeldag is True
async def test_update_planned_day_wish_id_and_is_pinned(
self,
planning_service: PlanningService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
wish = await leave_wish_repo.create(
_make_wish(seeded_ids.workspace_id, seeded_ids.child_id)
)
created = await planning_service.add_planned_day(
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date(2026, 6, 15),
day_type="vacation",
)
assert created.wish_id is None
updated = await planning_service.update(
created.id,
wish_id=wish.id,
is_pinned=True,
)
assert updated.wish_id == wish.id
assert updated.is_pinned is True
refetched = await planning_service.get(created.id)
assert refetched.wish_id == wish.id
assert refetched.is_pinned is True
async def test_update_missing_raises(
self,
planning_service: PlanningService,