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