Files
F-r-ldraledigplannerare/backend/app/services/leave_snapshot.py
T
jacobh2 97a7200c1a Parse transferable days from FK instead of deriving them
The scraper never read "överlåtbara dagar" from Försäkringskassan. It only
visited ansok-och-planera-foraldrapenning#/valjdagar and derived the
reserved/transferable split in build_snapshot, assuming transferable days
are spent before reserved ones. On real data that gave 49 for the user and
0 for the partner where FK states 129 and 71.

The assumption cannot be repaired: reserved-first, transferable-first,
proportional and cap-based models were all checked against real figures and
none reproduces FK's accounting (reserved-first fits the partner but is 10
days off for the user). FK publishes the real numbers per parent per tier on
the min-foraldrapenning page, which the scraper had never visited.

- Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction,
  summing sjukpenningnivå + lägstanivå per parent. The totals are not
  literals on the page. Parse only direct-child <p> elements: the first
  group embeds a tooltip with decoy "45 dagar"/"90 dagar" text.
- build_snapshot accepts the reported value and derives reserved days from
  it; the old arithmetic remains only as a fallback. An implausible FK value
  degrades to that fallback rather than failing the fetch.
- Record provenance in transferable_days_reported (migration 006) and badge
  derived values as estimates in the dashboard breakdown. Drop the footnote's
  claim that at most 150 days are transferable — real data contradicts it as
  a per-family figure.
- Select the child's section by name when FK lists several; never guess.

Verified against a live BankID scrape: scraped 240/101/139 with 129
transferable, deduced partner 240/169/71 with 71, both flagged as reported.

Incidental fixes found along the way:

- The fetch router never forwarded the partner's transferable figure to
  persist_partner_deduction, so the partner's stored snapshot would have kept
  the flawed split even once the parse landed.
- 'deduced' snapshots were labelled "Manually entered" by a two-way ternary
  over a three-value union. Replaced with a compiler-checked exhaustive map.
- Alembic resolved its database from alembic.ini alone, ignoring
  APP_SQLITE_PATH, so migrations silently targeted the developer's real
  database even when pointed elsewhere. env.py now resolves it, preferring
  -x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value.
- backend/captures/ was not ignored: the root pattern was anchored as
  /captures/ despite a comment saying scraper dumps must never be tracked.
- CLAUDE.md quoted test counts and coverage percentages that had gone stale,
  which made a correct verification report look suspect. Gate thresholds kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 09:11:53 +02:00

224 lines
9.5 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.
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,
)