"""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. FK's `min-foraldrapenning` e-service page does report the reserved/transferable split directly, in its "Dagar ni kan föra över" panel — when a caller has that figure (a scraper parse, or a detailed manual entry) it should be passed as `transferable_days_left`. The "transferable-first" arithmetic below is only a fallback for when that figure isn't available. 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, transferable_days_left: int | None = None, fetched_at: datetime | None = None, ) -> ParentalLeaveFetch: """Derive and validate a full `ParentalLeaveFetch` snapshot. `reserved_days_left` and `transferable_days_left` are mutually exclusive — passing both raises `ValidationError`, since each one alone fully determines the other (`transferable_days_left = days_left - reserved_days_left`). `transferable_days_left` is FK's own reported figure, read off the "Dagar ni kan föra över" panel on the `min-foraldrapenning` e-service page. When it is supplied, `reserved_days_left` is computed from it (`days_left - transferable_days_left`) and checked by the same validators below as any other value. When neither is supplied, `reserved_days_left` falls back to an assumption — transferable (non-reserved) days are always consumed before reserved ones — used only because FK's own figure wasn't available (manual entry with no split known, or a failed parse of that panel). When the caller already knows the reserved split directly (e.g. a future, more detailed manual form), passing `reserved_days_left` 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}, ) if reserved_days_left is not None and transferable_days_left is not None: raise ValidationError( "reserved_days_left and transferable_days_left are mutually exclusive " "— each one alone determines the other", message_key="parentalLeave.conflictingTransferableOverride", data={ "reserved_days_left": reserved_days_left, "transferable_days_left": transferable_days_left, }, ) transferable_days_reported = transferable_days_left is not None 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. FK reports # the transferable figure directly (see docstring); when it isn't # available, fall back to the long-standing assumption (matches FK's # own "spend transferable first") that transferable days are used up # before any reserved day is. reserved_days_total = rules.reserved_days_per_parent if transferable_days_left is not None: reserved_days_left = days_left - transferable_days_left elif reserved_days_left is not None: transferable_days_left = days_left - reserved_days_left else: 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 # -- 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, transferable_days_reported=transferable_days_reported, dubbeldagar_left=dubbeldagar_left, tier_data=tier_data, created_at=now, updated_at=now, )