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
+42 -16
View File
@@ -8,6 +8,7 @@ Scraper endpoints (start / poll / cancel) return 501 when scraper_enabled is
false. When enabled, they drive the FKScraperService session state machine.
"""
import logging
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
@@ -19,6 +20,7 @@ from app.dependencies import (
ChildRepositoryDep,
CurrentUserDep,
ParentalLeaveRepositoryDep,
PersonalDataRepositoryDep,
WorkspaceScopeDep,
)
from app.entities.parental_leave import ParentalLeaveFetch
@@ -27,10 +29,12 @@ from app.models.parental_leave import (
ManualFetchRequest,
ParentalLeaveFetchResponse,
)
from app.scrapers.fk_scraper import FKScraperService
from app.services.fetch_data_service import ManualFetchService
from app.scrapers.fk_scraper import FKScraperService, PartnerDeduction
from app.services.fetch_data_service import ManualFetchService, persist_partner_deduction
from app.utils.errors import ValidationError
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/parental-leave", tags=["parental-leave"])
scraper_router = APIRouter(prefix="/api/fetch-data", tags=["fetch-data"])
@@ -55,27 +59,27 @@ _scraper_service = FKScraperService()
async def save_manual_entry(
payload: ManualFetchRequest,
repo: ParentalLeaveRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
scope: WorkspaceScopeDep,
) -> ParentalLeaveFetchResponse:
scope.check_workspace(payload.workspace_id)
await scope.check_member(payload.personal_data_uuid)
await scope.check_child(payload.child_id)
service = ManualFetchService(repo)
# `pd_repo` lets the service default `total_days` off the workspace's
# parent count when the caller omits it — see
# `ManualFetchService.save_manual_entry`'s docstring.
service = ManualFetchService(repo, pd_repo)
try:
fetch = await service.save_manual_entry(
personal_data_uuid=payload.personal_data_uuid,
workspace_id=payload.workspace_id,
child_id=payload.child_id,
total_days=payload.total_days,
days_used=payload.days_used,
reserved_days_total=payload.reserved_days_total,
reserved_days_left=payload.reserved_days_left,
transferable_days_left=payload.transferable_days_left,
sjukpenning_left=payload.sjukpenning_left,
lagsta_niva_left=payload.lagsta_niva_left,
dubbeldagar_left=payload.dubbeldagar_left,
sjukpenning_total=payload.sjukpenning_total,
sjukpenning_used=payload.sjukpenning_used,
lagsta_niva_total=payload.lagsta_niva_total,
lagsta_niva_used=payload.lagsta_niva_used,
reserved_days_left=payload.reserved_days_left,
total_days=payload.total_days,
source=payload.source,
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
@@ -144,6 +148,7 @@ async def start_fetch(
scope: WorkspaceScopeDep,
child_repo: ChildRepositoryDep,
parental_leave_repo: ParentalLeaveRepositoryDep,
personal_data_repo: PersonalDataRepositoryDep,
) -> _StartResponse:
"""`workspace_id` is always the caller's own — `payload.workspace_id` is
accepted on the wire (frontend compatibility) but never trusted; the
@@ -169,11 +174,32 @@ async def start_fetch(
)
child_id = children[0].id
# Capture the repo for the background task. The connection lives for the app's
# lifetime (app.state.db_connection), so using it from the background coroutine
# is safe — aiosqlite serialises all operations through its internal queue.
async def _persist(fetch: ParentalLeaveFetch) -> None:
# Capture the repos for the background task. The connection lives for the
# app's lifetime (app.state.db_connection), so using it from the
# background coroutine is safe — aiosqlite serialises all operations
# through its internal queue.
async def _persist(fetch: ParentalLeaveFetch, partner: PartnerDeduction | None) -> None:
await parental_leave_repo.create(fetch)
if partner is not None:
# Best-effort: the caller's own snapshot above already succeeded,
# so a failure deducing/persisting the partner's snapshot must
# never fail the whole scraper session (`persist_partner_deduction`
# already swallows repo errors internally — this is a second,
# redundant layer in case that ever changes).
try:
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
workspace_id=workspace_id,
child_id=child_id,
caller_personal_data_uuid=personal_data_uuid,
total_days=partner.total_days,
sjukpenning_left=partner.sjukpenning_left,
lagsta_niva_left=partner.lagsta_niva_left,
dubbeldagar_left=partner.dubbeldagar_left,
)
except Exception as exc:
logger.warning("partner-deduction persist failed, session unaffected: %s", exc)
session_id = await _scraper_service.start_session(
workspace_id=workspace_id,
+2
View File
@@ -48,6 +48,7 @@ async def create_leave_wish(
parent_mode=payload.parent_mode,
parent_personal_data_uuid=payload.parent_personal_data_uuid,
window_mode=payload.window_mode,
wants_dubbeldag=payload.wants_dubbeldag,
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
@@ -96,6 +97,7 @@ async def update_leave_wish(
parent_mode=payload.parent_mode,
parent_personal_data_uuid=payload.parent_personal_data_uuid,
window_mode=payload.window_mode,
wants_dubbeldag=payload.wants_dubbeldag,
_clear_child_id="child_id" in fields_set and payload.child_id is None,
_clear_last_year="last_year" in fields_set and payload.last_year is None,
_clear_parent_personal_data_uuid=(
+12 -1
View File
@@ -83,10 +83,21 @@ async def sync_plan(
user has pinned, and reports exactly what changed (`changes`) so the UI
can show e.g. "Sommar 2028 flyttades 2 dagar — Ångra" instead of
silently rewriting the calendar.
Doubles as its own eviction preview: when the re-derived plan would
silently delete another wish's days (a shared FP/vacation pool ran out
for that wish's parent), this call writes NOTHING and returns
`eviction_preview` describing what ran out and which days would go —
unless `eviction_choice` names one of the two caller-authorized
eviction strategies, in which case exactly that category is freed and
the edit is applied. No separate round trip for the common (fits-
within-budget) case; see `PlanningService.sync_plan`'s docstring.
"""
scope.check_workspace(payload.workspace_id)
try:
result = await service.sync_plan(payload.workspace_id)
result = await service.sync_plan(
payload.workspace_id, eviction_choice=payload.eviction_choice
)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
except ValidationError as exc:
+1 -1
View File
@@ -84,7 +84,7 @@ async def update_workspace(
service: WorkspaceServiceDep,
) -> WorkspaceResponse:
try:
ws = await service.update_workspace(workspace_id, payload.name, payload.planning_mode)
ws = await service.update_workspace(workspace_id, payload.name)
except (NotFoundError, ValidationError) as exc:
code = (
status.HTTP_404_NOT_FOUND