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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user