97a7200c1a
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
224 lines
9.5 KiB
Python
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,
|
|
)
|