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
@@ -4,8 +4,8 @@ planning engine (see app.services.allocation_service module docstring).
Uses the same sqlite-backed fixtures as the rest of the service test suite
(tests/conftest.py) to seed a realistic workspace and verify the loader nets
out already-committed `planned_leave_day` rows, falls back to default
entitlement when no FK snapshot exists, and reads `planning_mode` off the
workspace.
entitlement when no FK snapshot exists, and resolves the age-4 savable-days
cap from parent count.
"""
from datetime import date, datetime
@@ -91,7 +91,6 @@ class TestAllocationInputLoader:
Workspace(
id=uuid4(),
name="Familj",
planning_mode="couple",
created_at=now,
)
)
@@ -200,10 +199,8 @@ class TestAllocationInputLoader:
)
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1))
assert alloc_input.planning_mode == "couple"
assert alloc_input.today == date(2027, 1, 1)
# Two parents in a 'couple' workspace -> per-parent age-4 cap (48),
# not the per-child figure (96).
# Two parents -> per-parent age-4 cap (48), not the per-child figure (96).
assert alloc_input.age4_savable_cap == 48
pool_with_fetch = alloc_input.pools[(parent_with_fetch.uuid, child.id)]
@@ -235,6 +232,123 @@ class TestAllocationInputLoader:
assert parent_facts_by_uuid[parent_with_fetch.uuid].min_vacation_days_per_year == 20
assert parent_facts_by_uuid[parent_without_fetch.uuid].min_vacation_days_per_year == 20
async def test_only_pinned_existing_hides_unpinned_wish_rows_but_all_existing_planned_keeps_them(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
"""§fix (parent-reassignment moves dates, decision B) — `sync_plan`
loads with `only_pinned_existing=True`, which must keep hiding
unpinned wish-attributed rows from `existing_planned` (unchanged
behaviour), but `all_existing_planned` must carry EVERY persisted
row regardless — that's what lets `_Ledger._persisted_by_wish` find
a flexible wish's own prior placement even when it's currently
hidden from `existing_planned`. See `AllocationInput
.all_existing_planned`'s docstring in allocation_service.py.
"""
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Parent",
salary_type="monthly",
employment_status="employed",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date(2026, 1, 1),
created_at=now,
)
)
wish = await leave_wish_repo.create(
LeaveWish(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
name="Höstlov",
window_start_month=10,
window_start_day=1,
window_end_month=10,
window_end_day=7,
target_weeks=1,
recurrence="once",
first_year=2027,
last_year=None,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
window_mode="flexible",
)
)
pinned = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
parent_personal_data_uuid=parent.uuid,
date=date(2027, 10, 1),
day_type="vacation",
is_pinned=True,
)
)
unpinned_wish_row = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
parent_personal_data_uuid=parent.uuid,
date=date(2027, 10, 4),
day_type="vacation",
wish_id=wish.id,
is_pinned=False,
)
)
ad_hoc = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws.id,
child_id=child.id,
parent_personal_data_uuid=parent.uuid,
date=date(2027, 10, 5),
day_type="vacation",
wish_id=None,
is_pinned=False,
)
)
loader = _make_loader(
workspace_repo,
child_repo,
personal_data_repo,
parental_leave_repo,
vacation_repo,
planned_leave_repo,
leave_wish_repo,
)
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1), only_pinned_existing=True)
visible_ids = {d.id for d in alloc_input.existing_planned}
assert visible_ids == {pinned.id, ad_hoc.id}
assert unpinned_wish_row.id not in visible_ids
assert alloc_input.all_existing_planned is not None
all_ids = {d.id for d in alloc_input.all_existing_planned}
assert all_ids == {pinned.id, unpinned_wish_row.id, ad_hoc.id}
async def test_load_reflects_a_custom_minimum_vacation_days(
self,
workspace_repo: SQLiteWorkspaceRepository,