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>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent 375bc64f2f
commit e4719cb9ae
71 changed files with 5705 additions and 1859 deletions
+224 -58
View File
@@ -33,15 +33,16 @@ import logging
import re
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Literal
from uuid import UUID
from app.config import settings
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.parental_leave import ParentalLeaveFetch
from app.services.fetch_data_service import FetchDataService
from app.services.leave_snapshot import build_snapshot
logger = logging.getLogger(__name__)
@@ -85,6 +86,46 @@ class _SEL:
# niva-statistik items inside forDig (desktop version, first = sjukpenning, second = lägstanivå)
NIVA_STATISTIK_DESKTOP = "[data-test='forDig'] .visa-dagar__desktop .niva-statistik__dagar span"
# "För dig och annan förälder" family panel — shows the CHILD's combined
# total ("Totalt för <barn>"), from which the other parent's remaining
# days can be deduced (family - mine). The exact DOM here is ASSUMED to
# mirror forDig's structure (same visa-dagar/niva-statistik classes)
# pending a real capture — see `fk_overview_synthetic.html`'s header
# comment. Parsing against these selectors must stay fully defensive
# (see `_extract_family_summary`): if FK's real markup differs, the
# feature degrades to "no partner deduction" rather than breaking the
# caller's own extraction.
FAMILY = "[data-test='forDigOchAnnanForalder']"
FAMILY_DAGAR_KVAR = FAMILY + " .visa-dagar__dagar-kvar"
FAMILY_DISCRETE = FAMILY + " .visa-dagar__discrete"
FAMILY_NIVA_STATISTIK = FAMILY + " .visa-dagar__desktop .niva-statistik__dagar span"
# ---------------------------------------------------------------------------
# Family-panel summary and partner deduction
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FamilySummary:
"""Parsed "Totalt för <barn>" family-panel figures (the CHILD's combined pool)."""
total_days: int
days_left: int
sjukpenning_left: int | None
lagsta_niva_left: int | None
@dataclass(frozen=True)
class PartnerDeduction:
"""The other parent's balance, deduced as family totals minus mine."""
total_days: int
days_left: int
sjukpenning_left: int
lagsta_niva_left: int
dubbeldagar_left: int
# ---------------------------------------------------------------------------
# Session state
@@ -110,12 +151,20 @@ class _SessionState:
result: ParentalLeaveFetch | None = None
message: str | None = None
created_at: datetime = field(default_factory=datetime.now)
# Family-panel summary and the deduced partner balance, when the family
# panel was present and its numbers were internally consistent with
# `result` — see `_extract_family_summary`/`_deduce_partner`. Both stay
# None whenever the family panel is absent, unparsable, or inconsistent;
# that never affects `result` (the caller's own extraction).
family: FamilySummary | None = None
partner_deduction: PartnerDeduction | None = None
# Called after a successful extraction to persist the extracted numbers.
# The callback receives the validated ParentalLeaveFetch and must write it to the DB.
# Only the extracted numbers are persisted — never cookies, QR, or session state.
persist_callback: Callable[[ParentalLeaveFetch], Awaitable[None]] | None = field(
default=None, repr=False, compare=False
)
# The callback receives the validated ParentalLeaveFetch (mine) and the
# deduced partner balance (or None). Only the extracted numbers are
# persisted — never cookies, QR, or session state.
persist_callback: (
Callable[[ParentalLeaveFetch, PartnerDeduction | None], Awaitable[None]] | None
) = field(default=None, repr=False, compare=False)
# asyncio task running the scrape; kept so we can cancel it
_task: asyncio.Task | None = field(default=None, repr=False, compare=False)
@@ -158,7 +207,9 @@ class FKScraperService(FetchDataService):
personal_data_uuid: UUID,
child_id: UUID,
owner_user_id: UUID,
persist_callback: Callable[[ParentalLeaveFetch], Awaitable[None]] | None = None,
persist_callback: (
Callable[[ParentalLeaveFetch, PartnerDeduction | None], Awaitable[None]] | None
) = None,
) -> str:
"""Launch a new headless browser session. Returns the session_id.
@@ -215,6 +266,9 @@ class FKScraperService(FetchDataService):
response["qr_data"] = base64.b64encode(state.qr_data).decode()
if state.status == "done" and state.result:
response["result"] = state.result
response["partner_deduction"] = (
asdict(state.partner_deduction) if state.partner_deduction else None
)
if state.message:
response["message"] = state.message
return response
@@ -363,6 +417,8 @@ async def _run_session(state: _SessionState) -> None:
if state.status in ("error", "cancelled"):
return
await _do_extract(page, state)
if state.status == "done" and settings.scraper_debug_dump_on_success:
await _save_debug_artifacts(page, state.session_id, console_messages)
except asyncio.CancelledError:
_set_status(state, "cancelled")
except Exception as exc:
@@ -503,9 +559,22 @@ async def _do_extract(page, state: _SessionState) -> None:
data = await _extract_parental_leave(page, state)
# Family panel ("för dig och annan förälder") -> deduce the partner's
# balance. Best-effort only: any failure here must never affect `data`,
# which is already a fully validated, ready-to-persist result.
try:
family = await _extract_family_summary(page, state)
state.family = family
if family is not None:
state.partner_deduction = _deduce_partner(data, family)
except Exception as exc:
logger.info("[%s] family-panel deduction skipped: %s", state.session_id, type(exc).__name__)
state.family = None
state.partner_deduction = None
if state.persist_callback is not None:
try:
await state.persist_callback(data)
await state.persist_callback(data, state.partner_deduction)
except Exception as exc:
_set_status(
state,
@@ -541,8 +610,7 @@ async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFe
"FK panel returned total_days=0 — data had not hydrated when read "
"(placeholder state). The hydration wait in _do_extract should prevent this."
)
days_used = total_days - days_left
logger.debug("[%s] total/used parsed", state.session_id)
logger.debug("[%s] total days parsed", state.session_id)
# -- sjukpenningnivå and lägstanivå remaining (desktop niva-statistik) --
# all_inner_texts() does not accept a timeout argument — wait for the first element first.
@@ -575,57 +643,155 @@ async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFe
dubbeldagar_left = int(dubbeldag_text.strip())
logger.debug("[%s] dubbeldagar parsed", state.session_id)
# -- Derive tier totals (standard FK allocation per parent) --
# Standard: 195 sjukpenningnivå + 45 lägstanivå = 240 per parent.
# Transfers can change this, but the page doesn't expose the totals directly.
# Assumption: lägstanivå days are never used before sjukpenning (most common case).
lagsta_niva_total = 45 # standard per-parent lägstanivå allocation (= rules.lagsta_niva_days/2)
sjukpenning_total = total_days - lagsta_niva_total # typically 195 for standard 240-day split
lagsta_niva_used = lagsta_niva_total - lagsta_niva_left
sjukpenning_used = sjukpenning_total - sjukpenning_left
# -- Derive reserved / transferable split --
# Reserved days (90 per parent) cannot be transferred; used assumption: transferable taken first.
reserved_days_total = settings.rules.reserved_days_per_parent # 90
transferable_total = total_days - reserved_days_total # 150 for standard 240-day case
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 ValueError(f"days_left is negative ({days_left}) — FK data inconsistency")
if reserved_days_left > reserved_days_total:
raise ValueError(
f"reserved_days_left ({reserved_days_left}) > reserved_days_total ({reserved_days_total})"
)
now = 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=uuid.uuid4(),
# -- Derive tier totals, reserved/transferable split, and validate --
# The page doesn't expose tier totals or the reserved/transferable split
# directly, only totals-per-tier-left and the grand total; everything
# else is derived by the single shared function also used by manual
# entry, so the two paths can never disagree (see
# `app.services.leave_snapshot.build_snapshot`'s docstring). This
# raises `ValidationError` (not `ValueError`) on bad data — see
# `tests/unit/test_fk_scraper.py::TestValidation` for the exact
# messages a caller may match on.
return build_snapshot(
personal_data_uuid=state.personal_data_uuid,
workspace_id=state.workspace_id,
child_id=state.child_id,
source="scraped",
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,
sjukpenning_left=sjukpenning_left,
lagsta_niva_left=lagsta_niva_left,
dubbeldagar_left=dubbeldagar_left,
tier_data=tier_data,
created_at=now,
updated_at=now,
)
async def _extract_family_summary(page, state: _SessionState) -> FamilySummary | None:
"""Read the "för dig och annan förälder" family panel, if present and parsable.
Defensive by design: the real FK DOM for this panel is unconfirmed (see
`_SEL.FAMILY*`'s comment), so any missing element or unexpected text is
treated as "no family data available" — logged at INFO and returned as
None — never raised. The caller's own `data` (this parent's snapshot)
must succeed regardless of what happens here.
"""
try:
family_panel = page.locator(_SEL.FAMILY)
if await family_panel.count() == 0:
logger.info("[%s] family panel not present; skipping deduction", state.session_id)
return None
dagar_kvar_loc = page.locator(_SEL.FAMILY_DAGAR_KVAR)
if await dagar_kvar_loc.count() == 0:
logger.info(
"[%s] family panel dagar-kvar element not found; skipping deduction",
state.session_id,
)
return None
dagar_kvar_text = await dagar_kvar_loc.first.inner_text(timeout=2_000)
days_left = int(dagar_kvar_text.strip())
discrete_loc = page.locator(_SEL.FAMILY_DISCRETE)
if await discrete_loc.count() == 0:
logger.info(
"[%s] family panel discrete element not found; skipping deduction",
state.session_id,
)
return None
discrete_text = await discrete_loc.first.inner_text(timeout=2_000)
total_match = re.search(r"av\s+(\d+)\s+dagar", discrete_text)
if not total_match:
logger.info(
"[%s] could not parse 'av N dagar' from family panel text; skipping deduction",
state.session_id,
)
return None
total_days = int(total_match.group(1))
# Tier spans are optional — best-effort, absence doesn't invalidate the summary.
sjukpenning_left: int | None = None
lagsta_niva_left: int | None = None
niva_loc = page.locator(_SEL.FAMILY_NIVA_STATISTIK)
if await niva_loc.count() > 0:
niva_texts = await niva_loc.all_inner_texts()
niva_numbers = []
for t in niva_texts:
m = re.search(r"\((\d+)\s+dagar\)", t)
if m:
niva_numbers.append(int(m.group(1)))
if len(niva_numbers) == 2:
break
if len(niva_numbers) == 2:
sjukpenning_left, lagsta_niva_left = niva_numbers[0], niva_numbers[1]
return FamilySummary(
total_days=total_days,
days_left=days_left,
sjukpenning_left=sjukpenning_left,
lagsta_niva_left=lagsta_niva_left,
)
except Exception as exc:
logger.info(
"[%s] family panel parsing failed (%s); skipping deduction",
state.session_id,
type(exc).__name__,
)
return None
def _deduce_partner(mine: ParentalLeaveFetch, family: FamilySummary) -> PartnerDeduction | None:
"""Deduce the other parent's balance as family totals minus mine.
Returns None (logging a warning) whenever the numbers are internally
inconsistent — e.g. the family panel reports fewer days than this parent
alone already accounts for, which can only mean a stale read or a DOM
assumption that doesn't match FK's real markup.
"""
total = family.total_days - mine.total_days
left = family.days_left - mine.days_left
if total <= 0 or left < 0 or left > total:
logger.warning(
"family/partner deduction inconsistent: family_total=%d family_left=%d "
"mine_total=%d mine_left=%d -> deduced_total=%d deduced_left=%d",
family.total_days,
family.days_left,
mine.total_days,
mine.days_left,
total,
left,
)
return None
rules = settings.rules
if family.sjukpenning_left is not None and family.lagsta_niva_left is not None:
sjuk_left = family.sjukpenning_left - mine.tier_data.sjukpenning_left
lagsta_left = family.lagsta_niva_left - mine.tier_data.lagsta_niva_left
if sjuk_left < 0 or lagsta_left < 0:
logger.warning(
"family/partner tier deduction inconsistent (sjuk=%d, lagsta=%d); "
"falling back to proportional split",
sjuk_left,
lagsta_left,
)
lagsta_share = (
rules.lagsta_niva_days * total // (rules.sjukpenning_days + rules.lagsta_niva_days)
)
lagsta_left = min(left, lagsta_share)
sjuk_left = left - lagsta_left
else:
# No family-level tier breakdown available — assume lägstanivå is
# untouched first (mirrors the same "transferable spent first"
# assumption `build_snapshot` makes for the reserved/transferable split).
lagsta_share = (
rules.lagsta_niva_days * total // (rules.sjukpenning_days + rules.lagsta_niva_days)
)
lagsta_left = min(left, lagsta_share)
sjuk_left = left - lagsta_left
return PartnerDeduction(
total_days=total,
days_left=left,
sjukpenning_left=sjuk_left,
lagsta_niva_left=lagsta_left,
dubbeldagar_left=mine.dubbeldagar_left,
)