Files
F-r-ldraledigplannerare/backend/app/services/leave_snapshot.py
T
jacobh2 e4719cb9ae 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>
2026-09-19 12:27:29 +02:00

188 lines
7.7 KiB
Python

"""Shared derivation for a `ParentalLeaveFetch` snapshot.
Both the FK scraper (`app.scrapers.fk_scraper`) and manual entry
(`app.services.fetch_data_service.ManualFetchService`) hand a parent's raw
"days left" numbers to `build_snapshot`, which is the ONE place that derives
everything else (tier totals, days used, reserved/transferable split) and
validates the result. Neither caller is allowed to re-derive any of this
itself — see PLANNING_ENGINE_SPEC.md and the "Bug A" note in
`app.utils.tip_predicates.default_tier_pool_share` for what happens when two
call sites disagree.
Domain: a child has `rules.total_days_per_child` days total (390
sjukpenningnivå + 90 lägstanivå). Split across two parents each gets half
(195 + 45 = 240); `rules.reserved_days_per_parent` (90) of each parent's
share is reserved (non-transferable). A sole parent gets the full 480
(390 + 90). `total_days` here is always the ONE PARENT's total (240 or 480),
never the per-child combined figure.
"""
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from app.config import settings
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.utils.errors import ValidationError
_VALID_SOURCES = ("scraped", "manual", "deduced")
def build_snapshot(
*,
personal_data_uuid: UUID,
workspace_id: UUID,
child_id: UUID,
source: str,
total_days: int,
sjukpenning_left: int,
lagsta_niva_left: int,
dubbeldagar_left: int,
reserved_days_left: int | None = None,
fetched_at: datetime | None = None,
) -> ParentalLeaveFetch:
"""Derive and validate a full `ParentalLeaveFetch` snapshot.
`reserved_days_left`, when omitted, is derived assuming transferable
(non-reserved) days are always consumed before reserved ones — the same
assumption the FK scraper's page reading has always made, since FK's UI
doesn't expose the reserved/transferable split directly. When the caller
already knows the reserved split (e.g. a future, more detailed manual
form), passing it directly skips that assumption.
"""
if source not in _VALID_SOURCES:
raise ValidationError(
f"source must be one of {_VALID_SOURCES}, got {source!r}",
message_key="parentalLeave.invalidSource",
data={"source": source},
)
rules = settings.rules
# -- Tier totals (standard FK allocation for this parent) --
# `total_days` is this ONE parent's total (240 with a partner, 480 solo);
# split it into tiers using the same 390:90 ratio as the whole-child pool
# so 240 -> (195, 45) and 480 -> (390, 90) without hardcoding either.
lagsta_niva_total = (
rules.lagsta_niva_days * total_days // (rules.sjukpenning_days + rules.lagsta_niva_days)
)
sjukpenning_total = total_days - lagsta_niva_total
days_left = sjukpenning_left + lagsta_niva_left
days_used = total_days - days_left
sjukpenning_used = sjukpenning_total - sjukpenning_left
lagsta_niva_used = lagsta_niva_total - lagsta_niva_left
# -- Reserved / transferable split --
# Reserved days cannot be transferred to the other parent; the
# long-standing assumption (matches FK's own "spend transferable first")
# is that transferable days are used up before any reserved day is.
reserved_days_total = rules.reserved_days_per_parent
if reserved_days_left is None:
transferable_total = total_days - reserved_days_total
transferable_used = min(days_used, transferable_total)
reserved_used = days_used - transferable_used
reserved_days_left = reserved_days_total - reserved_used
transferable_days_left = transferable_total - transferable_used
else:
transferable_days_left = days_left - reserved_days_left
# -- Validate before returning --
if days_left < 0:
raise ValidationError(
f"days_left is negative ({days_left}) — FK data inconsistency",
message_key="parentalLeave.negativeDaysLeft",
data={"days_left": days_left},
)
if sjukpenning_left < 0:
raise ValidationError(
f"sjukpenning_left is negative ({sjukpenning_left})",
message_key="parentalLeave.negativeSjukpenningLeft",
data={"sjukpenning_left": sjukpenning_left},
)
if sjukpenning_left > sjukpenning_total:
raise ValidationError(
f"sjukpenning_left ({sjukpenning_left}) exceeds sjukpenning_total "
f"({sjukpenning_total})",
message_key="parentalLeave.sjukpenningExceedsTotal",
data={"sjukpenning_left": sjukpenning_left, "sjukpenning_total": sjukpenning_total},
)
if lagsta_niva_left < 0:
raise ValidationError(
f"lagsta_niva_left is negative ({lagsta_niva_left})",
message_key="parentalLeave.negativeLagstaNivaLeft",
data={"lagsta_niva_left": lagsta_niva_left},
)
if lagsta_niva_left > lagsta_niva_total:
raise ValidationError(
f"lagsta_niva_left ({lagsta_niva_left}) exceeds lagsta_niva_total "
f"({lagsta_niva_total})",
message_key="parentalLeave.lagstaNivaExceedsTotal",
data={"lagsta_niva_left": lagsta_niva_left, "lagsta_niva_total": lagsta_niva_total},
)
if reserved_days_left < 0:
raise ValidationError(
f"reserved_days_left is negative ({reserved_days_left})",
message_key="parentalLeave.negativeReservedDaysLeft",
data={"reserved_days_left": reserved_days_left},
)
if reserved_days_left > reserved_days_total:
raise ValidationError(
f"reserved_days_left ({reserved_days_left}) > reserved_days_total "
f"({reserved_days_total})",
message_key="parentalLeave.reservedExceedsTotal",
data={
"reserved_days_left": reserved_days_left,
"reserved_days_total": reserved_days_total,
},
)
if transferable_days_left < 0:
raise ValidationError(
f"transferable_days_left is negative ({transferable_days_left})",
message_key="parentalLeave.negativeTransferableDaysLeft",
data={"transferable_days_left": transferable_days_left},
)
if dubbeldagar_left < 0:
raise ValidationError(
f"dubbeldagar_left is negative ({dubbeldagar_left})",
message_key="parentalLeave.negativeDubbeldagarLeft",
data={"dubbeldagar_left": dubbeldagar_left},
)
if dubbeldagar_left > rules.max_dubbeldagar:
raise ValidationError(
f"dubbeldagar_left ({dubbeldagar_left}) exceeds rules.max_dubbeldagar "
f"({rules.max_dubbeldagar})",
message_key="parentalLeave.dubbeldagarExceedsMax",
data={"dubbeldagar_left": dubbeldagar_left, "max_dubbeldagar": rules.max_dubbeldagar},
)
now = fetched_at or datetime.now()
tier_data = TierData(
sjukpenning_total=sjukpenning_total,
sjukpenning_used=sjukpenning_used,
sjukpenning_left=sjukpenning_left,
lagsta_niva_total=lagsta_niva_total,
lagsta_niva_used=lagsta_niva_used,
lagsta_niva_left=lagsta_niva_left,
)
return ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=personal_data_uuid,
workspace_id=workspace_id,
child_id=child_id,
source=source,
fetched_at=now,
total_days=total_days,
days_used=days_used,
days_left=days_left,
reserved_days_total=reserved_days_total,
reserved_days_left=reserved_days_left,
transferable_days_left=transferable_days_left,
dubbeldagar_left=dubbeldagar_left,
tier_data=tier_data,
created_at=now,
updated_at=now,
)