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