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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user