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>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent 375bc64f2f
commit e4719cb9ae
71 changed files with 5705 additions and 1859 deletions
@@ -101,6 +101,7 @@ def _wish(
window_mode: str = "fixed",
wish_id: UUID | None = None,
name_message_key: str | None = None,
wants_dubbeldag: bool = False,
) -> LeaveWish:
return LeaveWish(
id=wish_id or uuid4(),
@@ -121,6 +122,7 @@ def _wish(
parent_mode=parent_mode,
parent_personal_data_uuid=parent_uuid,
window_mode=window_mode,
wants_dubbeldag=wants_dubbeldag,
)
@@ -128,13 +130,13 @@ def _build_input(
*,
today: date,
horizon_end: date,
planning_mode: str = "solo",
children: list[ChildFacts],
parents: list[ParentFacts],
pools: dict[tuple[UUID, UUID], DayPool],
vacation: dict[tuple[UUID, int], VacationBudget],
wishes: list[LeaveWish],
existing_planned: list | None = None,
all_existing_planned: list | None = None,
extra_holiday_years: tuple[int, ...] = (),
day_overrides: dict[date, bool] | None = None,
age4_savable_cap: int | None = None,
@@ -145,12 +147,11 @@ def _build_input(
# `app.utils.tip_predicates.resolve_age4_savable_cap`. Computed here
# by default so existing callers that don't care about the age-4 cap
# split keep getting the historically-correct figure for their
# `planning_mode`/`parents` without having to pass it explicitly.
age4_savable_cap = resolve_age4_savable_cap(planning_mode, len(parents))
# `parents` count without having to pass it explicitly.
age4_savable_cap = resolve_age4_savable_cap(len(parents))
return AllocationInput(
today=today,
horizon_end=horizon_end,
planning_mode=planning_mode,
age4_savable_cap=age4_savable_cap,
children=children,
parents=parents,
@@ -160,18 +161,28 @@ def _build_input(
existing_planned=existing_planned or [],
holidays=_holidays_for(*years),
day_overrides=day_overrides or {},
all_existing_planned=all_existing_planned,
)
def _planned(
*, parent_uuid: UUID, child_id: UUID, on: date, day_type: str = "vacation"
*,
parent_uuid: UUID,
child_id: UUID,
on: date,
day_type: str = "vacation",
wish_id: UUID | None = None,
) -> PlannedLeaveDay:
"""A persisted `planned_leave_day` row — the "already applied, before
this `propose()` run" building block for the §fix (double-booking on
flexible re-propose) test cases below. Only the fields
`AllocationInput.existing_planned` actually reads
(`parent_personal_data_uuid`, `child_id`, `date`, `day_type`) matter.
flexible re-propose; extended for §fix parent-reassignment, decision B)
test cases below. Only the fields `AllocationInput.existing_planned`/
`all_existing_planned` actually read (`parent_personal_data_uuid`,
`child_id`, `date`, `day_type`, `wish_id`) matter. `wish_id` is what
`_Ledger._persisted_by_wish` now matches a flexible wish's own prior
placement by — pass the wish's `.id` whenever a test relies on that
reuse mechanism finding these rows.
"""
return PlannedLeaveDay(
id=uuid4(),
@@ -180,6 +191,7 @@ def _planned(
parent_personal_data_uuid=parent_uuid,
date=on,
day_type=day_type,
wish_id=wish_id,
)
@@ -209,7 +221,6 @@ class TestCase1SoloUnder12MonthsFallsBackToFP:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -247,7 +258,6 @@ class TestCase2CoupleSwap:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -291,7 +301,6 @@ class TestCase3MonthlySalaryVacationFirst:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -330,7 +339,6 @@ class TestCase4HourlySalaryFPFirst:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -365,7 +373,6 @@ class TestCase5ShortfallByPriority:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(30, 0, 60)},
@@ -401,7 +408,6 @@ class TestCase6YearWrapping:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2028, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -439,7 +445,6 @@ class TestCase7SeparatePoolsPerChild:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child_a, child_b],
parents=[parent],
pools={
@@ -474,7 +479,6 @@ class TestCase8DaysLostAtAge4Warning:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2029, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 0, 60)},
@@ -504,7 +508,6 @@ class TestCase9MovableEaster:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -529,7 +532,19 @@ class TestCase9MovableEaster:
class TestCase10DubbeldagOutsideWindow:
def test_falls_back_to_swap_when_outside_15_months(self) -> None:
"""§Decision A (co-leave, 2026-09-18 correction) REVERSES this case's old
product behaviour: a `parent_mode='both'` wish outside the 15-month
dubbeldagar window (or, per the correction, simply never opting in via
`wants_dubbeldag`) no longer falls back to a sequential parent-swap. It
now co-leaves: BOTH parents off on every SAME date, funded independently
(PL to whichever parent has more FP left, vacation to the other) —
"both" means both parents home together, not "each parent gets half the
weeks". See PLANNING_ENGINE_SPEC.md-adjacent handoff notes and
`allocation_service.py`'s `_Ledger._fund_both` docstring.
"""
def test_co_leave_places_both_parents_on_the_same_dates_when_outside_15_months(self) -> None:
child = _child(birth_date=date(2024, 1, 1)) # 15mo cutoff: 2025-04-01
parent_a = _parent(name="A")
parent_b = _parent(name="B")
@@ -539,11 +554,11 @@ class TestCase10DubbeldagOutsideWindow:
end=FEB_2027[1], # well after the 15-month window
weeks=4,
parent_mode="both",
# wants_dubbeldag defaults False -- opt-in only (Decision A).
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -566,6 +581,34 @@ class TestCase10DubbeldagOutsideWindow:
parent_b.uuid,
}
outcome = proposal.outcomes[0]
assert outcome.strategy == "co_leave"
assert outcome.requested_strategy == "co_leave"
assert outcome.strategy_degraded is False
assert outcome.shortfall_days == 0
# The headline product behaviour: every non-free day is BOTH parents
# on the SAME date (not parent_a taking the first half of the month
# and parent_b the second, which is what the old sequential-swap
# fallback would have produced), one funded from FP, the other from
# vacation.
non_free = [d for d in proposal.days if d.day_type != "free"]
by_date: dict[date, list] = {}
for d in non_free:
by_date.setdefault(d.date, []).append(d)
assert by_date, "expected at least one funded day"
for on_date, entries in by_date.items():
parents_that_day = {e.parent_personal_data_uuid for e in entries}
assert parents_that_day == {parent_a.uuid, parent_b.uuid}, (
f"{on_date}: expected both parents, got {parents_that_day}"
)
# Co-leave's PL+vacation split: exactly one FP day + one vacation
# day per date -- ample pools here mean it never has to fall back
# further, so this holds for every date, not just "on average".
types_that_day = sorted(e.day_type for e in entries)
assert types_that_day[1] == "vacation", (on_date, types_that_day)
assert types_that_day[0] in ("sjukpenning", "lagsta"), (on_date, types_that_day)
# ---------------------------------------------------------------------------
# #11 — re-run propose with identical input -> byte-identical output
@@ -581,7 +624,6 @@ class TestCase11Determinism:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -615,7 +657,6 @@ class TestCase12Age4HardCap:
alloc_input = _build_input(
today=date(2027, 1, 15),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 0, 60)},
@@ -655,7 +696,6 @@ class TestCase12Age4HardCap:
alloc_input = _build_input(
today=date(2027, 1, 15),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -705,7 +745,6 @@ class TestCase13PreschoolReserveRemedy:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31), # single year -> reserve = 6*1 = 6
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(15, 0, 60)},
@@ -789,7 +828,6 @@ class TestRegressionSoloVacationExhaustedFPAvailable:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2030, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
# Healthy FP pools — must be used as fallback when vacation runs out.
@@ -847,7 +885,6 @@ class TestRegressionSoloVacationExhaustedFPAvailable:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2028, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -955,7 +992,6 @@ class TestRegressionNoShortfallWhileResourcesRemain:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2028, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
@@ -1002,7 +1038,6 @@ class TestRegressionNoShortfallWhileResourcesRemain:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
# Total pool: 30 sjukpenning. Feb needs ~20, Aug needs ~22.
@@ -1048,7 +1083,6 @@ class TestRegressionGenuineShortfallStillReported:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
# Empty pools.
@@ -1087,7 +1121,6 @@ class TestRegressionGenuineShortfallStillReported:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
# Total 25 days: barely enough for Feb (20), but not Aug (22).
@@ -1132,7 +1165,6 @@ class TestRegressionTwoParentSwapUnaffected:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -1184,7 +1216,6 @@ class TestCase15FlexibleWishFindsEasterCluster:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1226,7 +1257,6 @@ class TestCase16FlexibleTieBreakByEarliestStart:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1275,7 +1305,6 @@ class TestFlexibleWishSizedByTargetDays:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1317,7 +1346,6 @@ class TestCase17TwoFlexibleWishesPriorityOrdering:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1370,7 +1398,6 @@ class TestCase18FixedWishAlwaysWinsOverFlexible:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1411,7 +1438,6 @@ class TestCase19FlexibleBoundShrunkByHorizonIsPartialNotUnplaced:
# 7 days target_weeks needs.
today=date(2027, 5, 20),
horizon_end=date(2027, 5, 25),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1456,7 +1482,6 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1509,7 +1534,6 @@ class TestCase21FlexibleYearlyWishTracksMovingEaster:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2029, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(400, 90, 60)},
@@ -1554,7 +1578,6 @@ class TestCase22FlexibleDubbeldagOutsideWindowFallsBack:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -1605,7 +1628,6 @@ class TestCase23FlexibleWishDeterminism:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -1653,7 +1675,6 @@ class TestCase24WindowModeDefaultsToFixed:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1698,13 +1719,14 @@ class TestCase25FlexibleAlreadyFundedYearProposesNothingNew:
# The exact 3 spendable days TestCase15 funds when this wish-year is
# searched fresh -- simulating a prior propose -> apply cycle.
existing = [
_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, d))
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, d), wish_id=wish.id
)
for d in (23, 24, 25)
]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1743,11 +1765,14 @@ class TestCase26FlexiblePartialFundingTopsUpRemainder:
window_mode="flexible",
)
# Only ONE of the winning window's 3 spendable days was ever applied.
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
existing = [
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23), wish_id=wish.id
)
]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1787,11 +1812,14 @@ class TestCase27FlexibleDeterminismWithExistingPlannedDays:
first_year=2027,
window_mode="flexible",
)
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
existing = [
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23), wish_id=wish.id
)
]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1820,10 +1848,6 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
def test_prior_window_abandoned_when_a_fixed_wish_claims_part_of_it(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
# Only 2027-03-23 was ever applied for "Vårlov" -- the other two
# spendable days of the Easter window (03-24, 03-25) were proposed
# last time but never applied.
existing = [_planned(parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 3, 23))]
# A brand-new fixed wish now claims 2027-03-25 -- one of the Easter
# window's still-open days -- and, per §3.9/§3.11.2, always wins.
blocker = _wish(
@@ -1844,10 +1868,20 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
first_year=2027,
window_mode="flexible",
)
# Only 2027-03-23 was ever applied for "Vårlov" -- the other two
# spendable days of the Easter window (03-24, 03-25) were proposed
# last time but never applied.
existing = [
_planned(
parent_uuid=parent.uuid,
child_id=child.id,
on=date(2027, 3, 23),
wish_id=flexible.id,
)
]
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
@@ -1919,7 +1953,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 5, 60)},
@@ -1963,7 +1996,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool:
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 5, 60)},
@@ -2025,11 +2057,15 @@ class TestCase23DubbeldagCountCapFallsBackToSwap:
weeks=26,
first_year=2023,
parent_mode="both",
# §Decision A (2026-09-18 correction): dubbeldagar are opt-in --
# must be explicitly requested, or `_fund_both` never attempts
# them at all and this cap-fallback scenario has nothing to
# fall back FROM.
wants_dubbeldag=True,
)
alloc_input = _build_input(
today=date(2023, 1, 1),
horizon_end=date(2023, 12, 31),
planning_mode="couple",
children=[child],
parents=[parent_a, parent_b],
pools={
@@ -2069,3 +2105,318 @@ class TestCase23DubbeldagCountCapFallsBackToSwap:
# The fallback only ever kicks in for dates after the cap was reached
# (`used` is monotonically increasing within the window).
assert min(leftover_parents_by_date) > max(dubbel_dates_a)
# ---------------------------------------------------------------------------
# §fix — reassigning a wish's parent must not move its dates (decision B,
# 2026-09-18 handoff: Höstlov reassigned from Amelie to Jacob jumped
# windows). `_reuse_already_funded_window` already existed for exactly this
# purpose but matched a wish's prior placement by (parent, child, date) --
# reassigning the parent changes `_resolve_parents`'s output BEFORE that
# match runs, so it silently stopped finding the wish's own history and fell
# through to a fresh, occupancy-blind search. Fixed by matching on `wish_id`
# instead (`_Ledger._persisted_by_wish`), which finds the prior window
# regardless of which parent it was funded under.
# ---------------------------------------------------------------------------
class TestFixReassigningParentDoesNotMoveDates:
def test_reassigning_the_parent_leaves_start_and_end_unchanged(self) -> None:
"""THE headline regression: same wish, same window fields, only
`parent_personal_data_uuid` changes (Amelie -> Jacob) -- the
already-applied window (2027-04-05..04-11) must be reused, funded
now for the new parent, not re-searched from scratch. A fresh,
occupancy-blind search over this bound (2027-04-05..05-31) lands on
2027-04-30..05-06 instead (cost 4 vs. the original week's cost 5 --
strictly cheaper by calendar) -- that is exactly the bug this test
pins down (verified against a live pre-fix run: pytest failed with
`assert datetime.date(2027, 4, 30) == datetime.date(2027, 4, 5)`).
"""
child = _child(birth_date=date(2020, 1, 1))
amelie = _parent(name="Amelie", salary_type="monthly")
jacob = _parent(name="Jacob", salary_type="monthly")
wish_id = uuid4()
# Amelie's prior apply: the Mon-Fri workdays of 2027-04-05..04-11
# (the weekend itself is never persisted -- free days are implicit,
# exactly like TestCase25/26's `existing` lists).
existing = [
_planned(
parent_uuid=amelie.uuid,
child_id=child.id,
on=date(2027, 4, d),
wish_id=wish_id,
)
for d in (5, 6, 7, 8, 9)
]
reassigned_wish = _wish(
wish_id=wish_id,
child_id=child.id,
name="Höstlov",
start=(4, 5),
end=(5, 31),
weeks=1,
first_year=2027,
window_mode="flexible",
parent_mode="specific",
parent_uuid=jacob.uuid, # <- the only thing that changed
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[amelie, jacob],
pools={
(amelie.uuid, child.id): DayPool(90, 90, 60),
(jacob.uuid, child.id): DayPool(90, 90, 60),
},
vacation={
(amelie.uuid, 2027): VacationBudget(25, 0, 0),
(jacob.uuid, 2027): VacationBudget(25, 0, 0),
},
wishes=[reassigned_wish],
existing_planned=existing,
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.start_date == date(2027, 4, 5)
assert outcome.end_date == date(2027, 4, 11)
assert outcome.shortfall_days == 0
# Funded now for the NEW parent, not the old one.
assert {d.parent_personal_data_uuid for d in proposal.days} == {jacob.uuid}
def test_editing_the_actual_dates_still_moves_it(self) -> None:
"""The flip side: when the user genuinely edits start/end (not just
the parent), the wish's placement DOES move -- reuse must never
pin a wish to a window its current bound no longer contains.
"""
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(name="Amelie", salary_type="monthly")
wish_id = uuid4()
existing = [
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 4, d), wish_id=wish_id
)
for d in (5, 6, 7, 8, 9)
]
# Same wish, same parent -- but the user dragged the period to
# September instead.
edited_wish = _wish(
wish_id=wish_id,
child_id=child.id,
name="Höstlov",
start=(9, 1),
end=(9, 30),
weeks=1,
first_year=2027,
window_mode="flexible",
parent_mode="specific",
parent_uuid=parent.uuid,
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[edited_wish],
existing_planned=existing,
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.start_date.month == 9
assert outcome.end_date.month == 9
assert (outcome.start_date, outcome.end_date) != (date(2027, 4, 5), date(2027, 4, 11))
# ---------------------------------------------------------------------------
# §Decision A (co-leave, 2026-09-18 correction) — dubbeldagar are opt-in.
# ---------------------------------------------------------------------------
class TestDubbeldagOptIn:
def _build(self, *, wants_dubbeldag: bool):
child = _child(birth_date=date(2027, 1, 1)) # 15mo cutoff: 2028-04-01
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
start=FEB_2027[0],
end=FEB_2027[1], # well inside the 15-month window
weeks=4,
parent_mode="both",
wants_dubbeldag=wants_dubbeldag,
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent_a, parent_b],
pools={
(parent_a.uuid, child.id): DayPool(100, 90, 60),
(parent_b.uuid, child.id): DayPool(100, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(20, 0, 0),
(parent_b.uuid, 2027): VacationBudget(20, 0, 0),
},
wishes=[wish],
)
return Allocator().propose(alloc_input)
def test_flag_unset_never_consumes_dubbeldagar_even_inside_the_window(self) -> None:
proposal = self._build(wants_dubbeldag=False)
assert not any(d.is_dubbeldag for d in proposal.days)
outcome = proposal.outcomes[0]
assert outcome.strategy == "co_leave"
assert outcome.requested_strategy == "co_leave"
assert outcome.strategy_degraded is False
# Legally available (child inside the window, quota free, both
# parents hold FP) even though it wasn't requested -- the frontend
# uses this to decide whether to SHOW the opt-in checkbox.
assert outcome.dubbeldagar_legally_available is True
def test_flag_set_consumes_dubbeldagar_inside_the_window(self) -> None:
proposal = self._build(wants_dubbeldag=True)
assert any(d.is_dubbeldag for d in proposal.days)
outcome = proposal.outcomes[0]
assert outcome.strategy == "dubbeldag"
assert outcome.requested_strategy == "dubbeldag"
assert outcome.strategy_degraded is False
assert outcome.dubbeldagar_legally_available is True
# ---------------------------------------------------------------------------
# §Decision C — candidate-window selection must be budget-aware, not just
# calendar-cheapest.
# ---------------------------------------------------------------------------
class TestFlexibleCandidateSelectionIsBudgetAware:
def test_unfundable_cheapest_candidate_is_skipped_for_a_fundable_one(self) -> None:
"""A single parent, single child. The wish's flexible bound
(2027-12-01..2028-02-28) contains a strictly-cheaper-by-calendar
window over the Christmas/New Year red-day cluster, but that
window's spendable days fall in 2027, a year with ZERO vacation and
ZERO FP left -- completely unfundable. A plain (costlier) week
entirely in 2028, where the parent has ample vacation, is not
calendar-cheapest but IS fully payable. Budget-aware ranking must
prefer the payable window; the old calendar-only `_candidate_cost`
ranking would have picked the unfundable one and reported a
shortfall despite a perfectly fundable window existing right next
to it in the same bound.
The very cheapest candidate overall (2027-12-31..2028-01-06) is
blocked by an unrelated pre-existing commitment on 2028-01-04 so
this test isolates the budget-vs-calendar-cost tradeoff rather than
that one coincidentally-fundable window (its 3 spendable days all
happen to fall in 2028 -- verified against a live run before writing
this test).
"""
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Jul",
start=(12, 1),
end=(1, 31), # wraps into 2028
weeks=1,
first_year=2027,
window_mode="flexible",
)
blocker = _planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2028, 1, 4), day_type="vacation"
)
alloc_input = _build_input(
today=date(2027, 11, 1),
horizon_end=date(2028, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(0, 0, 60)},
vacation={
(parent.uuid, 2027): VacationBudget(0, 0, 0),
(parent.uuid, 2028): VacationBudget(30, 0, 0),
},
wishes=[wish],
existing_planned=[blocker],
extra_holiday_years=(2028,),
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.shortfall_days == 0
assert outcome.start_date.year == 2028
assert outcome.end_date.year == 2028
# ---------------------------------------------------------------------------
# §Decision C — `strategy` must report what actually happened, never what
# was merely requested.
# ---------------------------------------------------------------------------
class TestStrategyReportsDegradation:
def test_both_mode_falling_back_to_swap_is_reported_not_hidden(self) -> None:
"""Parent A has FP but no partner-side vacation counterpart to pair
with; Parent B has neither FP nor vacation at all, so co-leave can
never pay Parent B's half of any date. Every date genuinely can't
be co-funded -> the whole window degrades to a sequential swap
(handed entirely to Parent A, who can afford it alone). `strategy`
must say 'swap', not silently keep claiming 'co_leave'.
"""
child = _child(birth_date=date(2020, 1, 1))
parent_a = _parent(name="A", salary_type="monthly")
parent_b = _parent(name="B", salary_type="monthly")
wish = _wish(
child_id=child.id,
start=FEB_2027[0],
end=FEB_2027[1],
weeks=4,
parent_mode="both",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent_a, parent_b],
pools={
(parent_a.uuid, child.id): DayPool(100, 0, 60),
(parent_b.uuid, child.id): DayPool(0, 0, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(30, 0, 0),
(parent_b.uuid, 2027): VacationBudget(0, 0, 0),
},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.requested_strategy == "co_leave"
assert outcome.strategy == "swap"
assert outcome.strategy_degraded is True
assert outcome.shortfall_days == 0 # parent A alone covers every spendable day
non_free_b = [
d
for d in proposal.days
if d.parent_personal_data_uuid == parent_b.uuid and d.day_type != "free"
]
assert non_free_b == [], "parent B cannot fund anything -- must get nothing, not a lie"