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
@@ -12,13 +12,40 @@ property this test file cares about most.
from datetime import date, datetime, timedelta
from uuid import uuid4
from app.entities.leave_wish import LeaveWish
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.services.planning_service import PlanningService, ProposedDayInput
from tests.conftest import SeededIds
def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish:
"""A `leave_wish` row for FK-constrained `planned_leave_day.wish_id` tests."""
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)
def _make_fetch(
personal_data_uuid,
workspace_id,
@@ -226,6 +253,145 @@ class TestApplyProposedDaysCollisions:
assert by_date[date(2026, 6, 16)].day_type == "vacation"
class TestApplyProposedDaysWishAttribution:
"""`wish_id`/`is_pinned` are new fields on `ProposedDayInput` — the
keystone that lets the UI later click a multi-day band on the calendar
and edit it as one period. This covers them surviving the write path and
the "metadata-only update" carve-out from the collision rules.
"""
async def test_wish_id_and_is_pinned_survive_create(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
wish = await leave_wish_repo.create(_make_wish(ws, child))
wish_id = wish.id
days = [
ProposedDayInput(
date(2026, 6, 15),
parent,
child,
"vacation",
wish_id=wish_id,
is_pinned=True,
),
]
result = await planning_service.apply_proposed_days(ws, days)
assert result.accepted is True
assert result.created_count == 1
assert result.created[0].wish_id == wish_id
assert result.created[0].is_pinned is True
rows = await planned_leave_repo.list_by_workspace_child(
workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30)
)
assert len(rows) == 1
assert rows[0].wish_id == wish_id
assert rows[0].is_pinned is True
async def test_ad_hoc_day_has_no_wish_id(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
days = [ProposedDayInput(date(2026, 6, 15), parent, child, "vacation")]
result = await planning_service.apply_proposed_days(ws, days)
assert result.created[0].wish_id is None
assert result.created[0].is_pinned is False
async def test_reapply_with_different_wish_id_updates_attribution_not_collision(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Same day_type/is_dubbeldag but a different `wish_id` must be a
plain metadata update — not a collision requiring `replace=True`,
and it must not be silently dropped as a no-op skip either.
"""
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
wish_a = (await leave_wish_repo.create(_make_wish(ws, child, name="A"))).id
wish_b = (await leave_wish_repo.create(_make_wish(ws, child, name="B"))).id
first = await planning_service.apply_proposed_days(
ws, [ProposedDayInput(date(2026, 6, 15), parent, child, "vacation", wish_id=wish_a)]
)
assert first.created[0].wish_id == wish_a
original_id = first.created[0].id
second = await planning_service.apply_proposed_days(
ws,
[ProposedDayInput(date(2026, 6, 15), parent, child, "vacation", wish_id=wish_b)],
replace=False,
)
assert second.accepted is True
assert second.collisions == []
assert second.skipped_identical_count == 1
rows = await planned_leave_repo.list_by_workspace_child(
workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30)
)
assert len(rows) == 1
assert rows[0].id == original_id # same row, not replaced
assert rows[0].wish_id == wish_b # attribution updated
async def test_batch_internal_duplicate_with_different_wish_id_last_one_wins(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Two entries for the same (child, parent, date) that agree on
day_type/is_dubbeldag but differ only in `wish_id` must not trip the
conflicting-duplicate structural rejection.
"""
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
wish_a = (await leave_wish_repo.create(_make_wish(ws, child, name="A"))).id
wish_b = (await leave_wish_repo.create(_make_wish(ws, child, name="B"))).id
days = [
ProposedDayInput(date(2026, 6, 15), parent, child, "vacation", wish_id=wish_a),
ProposedDayInput(date(2026, 6, 15), parent, child, "vacation", wish_id=wish_b),
]
result = await planning_service.apply_proposed_days(ws, days)
assert result.accepted is True
assert result.rejections == []
rows = await planned_leave_repo.list_by_workspace_child(
workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30)
)
assert len(rows) == 1
assert rows[0].wish_id == wish_b # later entry wins
class TestApplyProposedDaysValidation:
async def test_invalid_day_type_rejected_before_any_write(
self,