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
+63 -34
View File
@@ -135,7 +135,11 @@ async def _set_full_parental_leave(
"""Record a manual FK snapshot via the real `/api/parental-leave/manual`
endpoint (the always-available alternative to the scraper), defaulting
to a full, untouched 390+90 pool.
to a full, untouched 390+90 pool. `reserved_days_left` is left for
`build_snapshot` to derive (transferable-spent-first) rather than
hardcoded, since a hardcoded 90 would be inconsistent with a
(sjukpenning_used, lagsta_niva_used) combination that drains past the
150 transferable days into the reserved ones.
"""
resp = await client.post(
"/api/parental-leave/manual",
@@ -145,15 +149,9 @@ async def _set_full_parental_leave(
"workspace_id": str(workspace_id),
"child_id": str(child_id),
"total_days": 480,
"days_used": sjukpenning_used + lagsta_niva_used,
"reserved_days_total": 90,
"reserved_days_left": 90,
"transferable_days_left": 150,
"sjukpenning_left": 390 - sjukpenning_used,
"lagsta_niva_left": 90 - lagsta_niva_used,
"dubbeldagar_left": 60,
"sjukpenning_total": 390,
"sjukpenning_used": sjukpenning_used,
"lagsta_niva_total": 90,
"lagsta_niva_used": lagsta_niva_used,
},
)
assert resp.status_code == 201, resp.text
@@ -227,6 +225,15 @@ def _to_apply_item(d: dict[str, Any]) -> dict[str, Any]:
"child_id": d["child_id"],
"day_type": d["day_type"],
"is_dubbeldag": d["is_dubbeldag"],
# §fix (parent-reassignment moves dates, decision B) — a faithful
# caller round-trips `wish_id` from the propose response exactly
# like every other field here; omitting it used to be harmless
# (the old reuse match was parent-based) but `_persisted_by_wish`
# now needs it to recognise a flexible wish's own prior placement
# on the next propose (see allocation_service.py's `_Ledger
# .__init__`). Real callers already send it -- `ProposedDayResponse`
# (and hence this `d`) always carries a non-null `wish_id`.
"wish_id": d["wish_id"],
}
@@ -725,17 +732,21 @@ class TestAge4ProjectionArithmeticIsSelfConsistent:
(`allocation_service.py:1446-1475`). No wish is created in this
test, so nothing gets proposed and `planned_fp_before_b4` is 0.
With no `ParentalLeaveFetch` snapshot for this brand-new
registration, that pool falls back to `rules.sjukpenning_days // 2 +
rules.lagsta_niva_days // 2`, which is `rules.days_per_parent`
exactly (the "Bug A" default the two must agree on -- see
registration, that pool falls back to `default_tier_pool_share`
(`app.utils.tip_predicates`), which gives a SOLE parent (this
workspace has exactly one member) the full
`rules.sjukpenning_days`/`rules.lagsta_niva_days` pool rather than
half of it -- summing to `rules.total_days_per_child` exactly (the
"Bug A" default the allocation engine and `PlanningService.
_tier_pool_left` must agree on -- see
`test_planning_service_apply.py`'s
`test_no_snapshot_default_balance_matches_allocation_engine`) --
making `projected_left_at_b4 == rules.days_per_parent`. The
making `projected_left_at_b4 == rules.total_days_per_child`. The
workspace is a fresh single-member 'solo' workspace (never set to
'couple' here), so the per-CHILD cap
(`rules.max_savable_days_at_age_4`) applies via
`resolve_age4_savable_cap`, not the per-parent one -- making
`expiring_at_b4 == max(rules.days_per_parent -
`expiring_at_b4 == max(rules.total_days_per_child -
rules.max_savable_days_at_age_4, 0)`.
"""
headers, ws_id, pd_uuid = await _register(client, "age4-projection@example.com", "Alex")
@@ -748,7 +759,7 @@ class TestAge4ProjectionArithmeticIsSelfConsistent:
rules = settings.rules
expected_cap = rules.max_savable_days_at_age_4
expected_projected_left = rules.days_per_parent
expected_projected_left = rules.total_days_per_child
expected_expiring = max(expected_projected_left - expected_cap, 0)
expected_savable = min(expected_projected_left, expected_cap)
@@ -846,22 +857,22 @@ class TestWorkspaceParentsRenameAndRemove:
assert remove_self.status_code == 422, remove_self.text
assert remove_self.json()["detail"]["message_key"] == "workspace.parents.remove.self"
# --- remove: refused while dependent data exists -------------------
await _set_vacation(client, headers, UUID(placeholder_uuid), ws_id, date.today().year)
remove_blocked = await client.delete(
# --- remove: happy path, on an unencumbered placeholder ------------
# Done here (before any dependent data exists on Samuel) rather than
# via a third placeholder, since the workspace is capped at 2
# parents — see the cap test below.
delete_resp = await client.delete(
f"/api/workspace/{ws_id}/parents/{placeholder_uuid}", headers=headers
)
assert remove_blocked.status_code == 422, remove_blocked.text
blocked_detail = remove_blocked.json()["detail"]
assert blocked_detail["message_key"] == "workspace.parents.remove.hasDependentData"
assert "vacation_days" in blocked_detail["data"]["blockers"]
assert delete_resp.status_code == 204, delete_resp.text
assert delete_resp.content == b""
# Still a member — never silently removed despite the refusal.
members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers)
assert placeholder_uuid in {m["uuid"] for m in members_resp.json()}
member_uuids = {m["uuid"] for m in members_resp.json()}
assert placeholder_uuid not in member_uuids
assert member_uuids == {str(pd_uuid)}
# --- remove: happy path, on a second (unencumbered) placeholder --
# --- add: a second placeholder, to re-fill the workspace -----------
add_resp2 = await client.post(
f"/api/workspace/{ws_id}/parents",
headers=headers,
@@ -870,18 +881,36 @@ class TestWorkspaceParentsRenameAndRemove:
assert add_resp2.status_code == 201, add_resp2.text
other_placeholder_uuid = add_resp2.json()["uuid"]
delete_resp = await client.delete(
f"/api/workspace/{ws_id}/parents/{other_placeholder_uuid}", headers=headers
# --- add: capped at 2 parents per workspace ------------------------
# Alex + Kim already fill the workspace's 2-parent cap, so a third
# add is refused (and never persisted) before any dependent-data
# concern is even relevant.
cap_resp = await client.post(
f"/api/workspace/{ws_id}/parents",
headers=headers,
json={"display_name": "Robin"},
)
assert delete_resp.status_code == 204, delete_resp.text
assert delete_resp.content == b""
assert cap_resp.status_code == 422, cap_resp.text
assert cap_resp.json()["detail"]["message_key"] == "workspace.parents.add.maxReached"
members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers)
member_uuids = {m["uuid"] for m in members_resp.json()}
assert other_placeholder_uuid not in member_uuids
# The blocked placeholder and the caller are both still members.
assert placeholder_uuid in member_uuids
assert str(pd_uuid) in member_uuids
assert member_uuids == {other_placeholder_uuid, str(pd_uuid)}
# --- remove: refused while dependent data exists -------------------
await _set_vacation(client, headers, UUID(other_placeholder_uuid), ws_id, date.today().year)
remove_blocked = await client.delete(
f"/api/workspace/{ws_id}/parents/{other_placeholder_uuid}", headers=headers
)
assert remove_blocked.status_code == 422, remove_blocked.text
blocked_detail = remove_blocked.json()["detail"]
assert blocked_detail["message_key"] == "workspace.parents.remove.hasDependentData"
assert "vacation_days" in blocked_detail["data"]["blockers"]
# Still a member — never silently removed despite the refusal.
members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers)
assert other_placeholder_uuid in {m["uuid"] for m in members_resp.json()}
class TestLeaveWishNameMessageKeyRoundTrip: