e4719cb9ae
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>
188 lines
7.7 KiB
Python
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,
|
|
)
|