e4719cb9ae
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>
1307 lines
59 KiB
Python
1307 lines
59 KiB
Python
"""Business logic for planned leave days (calendar planning)."""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import date, datetime
|
|
from uuid import UUID, uuid4
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from app.config import settings
|
|
from app.entities.planned_leave import PlannedLeaveDay
|
|
from app.repositories.leave_wish_repository import LeaveWishRepository
|
|
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
|
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
|
from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork
|
|
from app.repositories.vacation_days_repository import VacationDaysRepository
|
|
from app.services.allocation_service import PlanningEngineService, PlanProposal, WindowOutcome
|
|
from app.services.analytics_service import AnalyticsService
|
|
from app.utils.errors import DubbeldagError, NotFoundError, ValidationError
|
|
from app.utils.tip_predicates import default_tier_pool_share
|
|
|
|
# The two day-type buckets a persisted day can belong to for eviction
|
|
# purposes -- see `EvictionCandidate`/`ResourceShortage` below. Exhaustive and
|
|
# mutually exclusive with `_PERSISTABLE_DAY_TYPES` (defined below).
|
|
_PL_DAY_TYPES = {"sjukpenning", "lagsta"}
|
|
|
|
# `sync_plan`'s two caller-authorized eviction strategies (§ eviction-preview
|
|
# feature). Anything else (including `None`, the default) means "do nothing":
|
|
# never evict without an explicit instruction.
|
|
EVICTION_CHOICE_DROP_FUTURE_PL = "drop_future_pl"
|
|
EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR = "drop_latest_vacation_this_year"
|
|
|
|
# Day types that can actually be persisted as a `planned_leave_day` row. A
|
|
# proposal may also contain 'free' entries (automatic weekend/red-day
|
|
# non-workdays, see allocation_service.ProposedDay) purely for explainability
|
|
# — those never become rows here, see `apply_proposed_days`.
|
|
_PERSISTABLE_DAY_TYPES = {"sjukpenning", "lagsta", "vacation"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/plan/apply — bulk accept of a (previously proposed) set of days.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProposedDayInput:
|
|
"""One day out of a batch handed to `apply_proposed_days`.
|
|
|
|
Deliberately a plain domain dataclass (not a Pydantic model) so the
|
|
service stays independent of the wire schema in `app.models.plan` — the
|
|
router is responsible for that translation. Shape mirrors
|
|
`allocation_service.ProposedDay` minus the explain-only `reason` field,
|
|
which carries no persistence meaning. `wish_id` DOES carry persistence
|
|
meaning (unlike `reason`): it links the persisted `planned_leave_day` row
|
|
back to the `LeaveWish` that produced it (nullable — ad-hoc/legacy days
|
|
have none), which is what lets the UI later treat a multi-day band as one
|
|
editable period instead of one day at a time.
|
|
"""
|
|
|
|
date: date
|
|
parent_personal_data_uuid: UUID
|
|
child_id: UUID
|
|
day_type: str
|
|
is_dubbeldag: bool = False
|
|
wish_id: UUID | None = None
|
|
# True once the user hand-edits this day; see `PlannedLeaveDay.is_pinned`.
|
|
is_pinned: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DayCollision:
|
|
"""A date already planned differently than what the batch proposes."""
|
|
|
|
date: date
|
|
parent_personal_data_uuid: UUID
|
|
child_id: UUID
|
|
existing_day_type: str
|
|
proposed_day_type: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Rejection:
|
|
"""A single reason a batch (or part of it) was refused.
|
|
|
|
§3.5: mirrors `tips_service.Tip`'s `message_key` + `data` shape rather
|
|
than a pre-rendered string, so `PlanApplyResponse.rejections` can be
|
|
rendered in the caller's active locale instead of leaking backend-
|
|
generated (previously English-only, regardless of locale) text.
|
|
"""
|
|
|
|
message_key: str
|
|
data: dict[str, object]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PlanApplyResult:
|
|
accepted: bool
|
|
created: list[PlannedLeaveDay] = field(default_factory=list)
|
|
created_count: int = 0
|
|
skipped_identical_count: int = 0
|
|
skipped_free_count: int = 0
|
|
replaced_count: int = 0
|
|
deleted_count: int = 0
|
|
collisions: list[DayCollision] = field(default_factory=list)
|
|
rejections: list[Rejection] = field(default_factory=list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/plan/sync — propose + apply in one step, with a structured diff.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PlanChange:
|
|
"""One (child, parent, date) slot's delta from `sync_plan`'s pre-write
|
|
|
|
snapshot to what's actually persisted after the call. Structured data
|
|
only — house style is `message_key` + `data`, never backend-rendered
|
|
prose, so the frontend composes the toast copy ("Sommar 2028 flyttades 2
|
|
dagar") itself. `kind`:
|
|
|
|
- 'added' — no row existed for (child, parent, date); one does now.
|
|
- 'removed' — a row existed and no longer does (old_* populated, new_*
|
|
all `None`).
|
|
- 'retyped' — the row survived but `day_type`/`is_dubbeldag` changed.
|
|
- 'reattributed' — same day and type, but `wish_id` changed (the day now
|
|
belongs to a different period).
|
|
"""
|
|
|
|
kind: str
|
|
date: date
|
|
parent_personal_data_uuid: UUID
|
|
child_id: UUID
|
|
old_day_type: str | None
|
|
new_day_type: str | None
|
|
old_is_dubbeldag: bool | None
|
|
new_is_dubbeldag: bool | None
|
|
old_wish_id: UUID | None
|
|
new_wish_id: UUID | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvictionCandidate:
|
|
"""One previously-persisted day a fresh `sync_plan` would otherwise
|
|
|
|
silently delete because a *different* wish's edit consumed the shared
|
|
pool ahead of it (see `PlanningService._genuinely_evicted_rows` for the
|
|
"genuinely evicted" vs. "this wish's own window shrank" distinction --
|
|
only the former ever appears here). Carries everything the frontend
|
|
needs to name the day without the backend pre-rendering prose:
|
|
`wish_name` is resolved from the *pre-write* `LeaveWish` row (`None`
|
|
when the service has no `LeaveWishRepository` wired, or the wish's name
|
|
can't be found -- degrades gracefully rather than failing the preview).
|
|
"""
|
|
|
|
date: date
|
|
parent_personal_data_uuid: UUID
|
|
child_id: UUID
|
|
day_type: str
|
|
is_dubbeldag: bool
|
|
wish_id: UUID
|
|
wish_name: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResourceShortage:
|
|
"""One exhausted resource implicated by an eviction preview.
|
|
|
|
Names precisely what ran out (§3 of the eviction-preview requirement):
|
|
`resource_kind == 'parental_leave'` carries `tier` + `child_id` (a
|
|
specific sjukpenning/lagsta pool for one parent+child); `resource_kind
|
|
== 'vacation'` carries `year` instead (`tier`/`child_id` are `None` --
|
|
vacation is budgeted per parent+year, not per child). Never a
|
|
pre-rendered string -- the frontend composes the warning copy itself.
|
|
"""
|
|
|
|
resource_kind: str # 'parental_leave' | 'vacation'
|
|
parent_personal_data_uuid: UUID
|
|
tier: str | None
|
|
child_id: UUID | None
|
|
year: int | None
|
|
days_evicted: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvictionPreview:
|
|
"""What a `sync_plan` call would silently delete, and why -- always
|
|
|
|
present on `PlanSyncResult` (empty/inert when nothing would be
|
|
evicted, so the common case costs the caller nothing extra to check).
|
|
"""
|
|
|
|
would_evict: bool
|
|
candidates: list[EvictionCandidate] = field(default_factory=list)
|
|
shortages: list[ResourceShortage] = field(default_factory=list)
|
|
|
|
|
|
_NO_EVICTION_PREVIEW = EvictionPreview(would_evict=False)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PlanSyncResult:
|
|
proposal: PlanProposal
|
|
apply_result: PlanApplyResult
|
|
changes: list[PlanChange] = field(default_factory=list)
|
|
eviction_preview: EvictionPreview = _NO_EVICTION_PREVIEW
|
|
|
|
|
|
class PlanningService:
|
|
def __init__(
|
|
self,
|
|
repo: PlannedLeaveRepository,
|
|
analytics_service: AnalyticsService,
|
|
parental_leave_repo: ParentalLeaveRepository,
|
|
vacation_repo: VacationDaysRepository,
|
|
planning_engine_service: PlanningEngineService | None = None,
|
|
leave_wish_repo: LeaveWishRepository | None = None,
|
|
uow: UnitOfWork | None = None,
|
|
) -> None:
|
|
self._repo = repo
|
|
self._analytics = analytics_service
|
|
self._parental_leave_repo = parental_leave_repo
|
|
self._vacation_repo = vacation_repo
|
|
# Only needed by `sync_plan` (POST /api/plan/sync) — optional so
|
|
# existing callers (scripts/seed_demo.py, older fixtures) that only
|
|
# ever use the per-day CRUD/`apply_proposed_days` paths don't need to
|
|
# wire up a `PlanningEngineService` they'll never use.
|
|
self._engine = planning_engine_service
|
|
# Only needed by `sync_plan`'s eviction preview, to resolve a stale
|
|
# row's `wish_id` back to a human-readable name (`EvictionCandidate.
|
|
# wish_name`) -- optional for the same reason `_engine` is: existing
|
|
# direct-construction call sites that never hit the eviction path
|
|
# don't need to wire one up. `wish_name` degrades to `None` when this
|
|
# is unset, never raises.
|
|
self._leave_wish_repo = leave_wish_repo
|
|
# Defaults to a no-op so pre-existing direct-construction call sites
|
|
# (scripts/seed_demo.py, test fixtures) keep today's per-write-commit
|
|
# behavior; `app.dependencies.get_planning_service` always wires a
|
|
# real `SQLiteUnitOfWork`.
|
|
self._uow = uow or NoOpUnitOfWork()
|
|
|
|
async def add_planned_day(
|
|
self,
|
|
workspace_id: UUID,
|
|
child_id: UUID,
|
|
parent_personal_data_uuid: UUID,
|
|
date: date,
|
|
day_type: str,
|
|
is_dubbeldag: bool = False,
|
|
wish_id: UUID | None = None,
|
|
is_pinned: bool = False,
|
|
) -> PlannedLeaveDay:
|
|
"""Add a planned leave day.
|
|
|
|
Validates that the day is available (dubbeldagar rules, no double-booking).
|
|
"""
|
|
# Validation 1: Check dubbeldagar quota if this is a dubbeldag
|
|
if is_dubbeldag:
|
|
await self._analytics.validate_dubbeldagar(child_id, date)
|
|
|
|
# Validation 2: Check no double-booking on the same date for the same parent
|
|
existing = await self._repo.list_by_workspace_child(
|
|
workspace_id=workspace_id,
|
|
child_id=child_id,
|
|
from_date=date,
|
|
to_date=date,
|
|
)
|
|
# Only reject if the same parent already has this date planned
|
|
parent_existing = [
|
|
e for e in existing if e.parent_personal_data_uuid == parent_personal_data_uuid
|
|
]
|
|
if parent_existing:
|
|
raise ValidationError(f"Parent already has a planned day on {date}")
|
|
|
|
now = datetime.now()
|
|
entity = PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=workspace_id,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_personal_data_uuid,
|
|
date=date,
|
|
day_type=day_type,
|
|
is_dubbeldag=is_dubbeldag,
|
|
wish_id=wish_id,
|
|
is_pinned=is_pinned,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
return await self._repo.create(entity)
|
|
|
|
async def remove_planned_day(
|
|
self, planned_leave_day_id: UUID, workspace_id: UUID | None = None
|
|
) -> None:
|
|
"""Remove a planned leave day."""
|
|
await self.get(
|
|
planned_leave_day_id, workspace_id
|
|
) # raises NotFoundError if missing/foreign
|
|
await self._repo.delete(planned_leave_day_id)
|
|
|
|
async def list_by_workspace(self, workspace_id: UUID) -> list[PlannedLeaveDay]:
|
|
"""Get all planned days for a workspace."""
|
|
return await self._repo.list(workspace_id=workspace_id)
|
|
|
|
async def list_by_date_range(
|
|
self,
|
|
workspace_id: UUID,
|
|
child_id: UUID,
|
|
start_date: date,
|
|
end_date: date,
|
|
) -> list[PlannedLeaveDay]:
|
|
"""Get planned days within a date range for a child."""
|
|
return await self._repo.list_by_workspace_child(
|
|
workspace_id=workspace_id,
|
|
child_id=child_id,
|
|
from_date=start_date,
|
|
to_date=end_date,
|
|
)
|
|
|
|
async def get(
|
|
self, planned_leave_day_id: UUID, workspace_id: UUID | None = None
|
|
) -> PlannedLeaveDay:
|
|
"""Get a planned day by ID. `workspace_id`, when given, must match the
|
|
row's own — a foreign workspace's row 404s exactly like a nonexistent
|
|
one."""
|
|
entity = await self._repo.get_by_id(planned_leave_day_id)
|
|
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
|
|
raise NotFoundError(f"PlannedLeaveDay {planned_leave_day_id} not found")
|
|
return entity
|
|
|
|
async def list_by_child(self, child_id: UUID) -> list[PlannedLeaveDay]:
|
|
"""Get all planned days for a child across workspaces."""
|
|
return await self._repo.list(child_id=child_id)
|
|
|
|
async def update(
|
|
self,
|
|
planned_leave_day_id: UUID,
|
|
day_type: str | None = None,
|
|
is_dubbeldag: bool | None = None,
|
|
wish_id: UUID | None = None,
|
|
is_pinned: bool | None = None,
|
|
workspace_id: UUID | None = None,
|
|
) -> PlannedLeaveDay:
|
|
"""Update a planned day (partial update)."""
|
|
entity = await self.get(planned_leave_day_id, workspace_id)
|
|
|
|
if day_type is not None:
|
|
entity.day_type = day_type
|
|
if is_dubbeldag is not None:
|
|
entity.is_dubbeldag = is_dubbeldag
|
|
if wish_id is not None:
|
|
entity.wish_id = wish_id
|
|
if is_pinned is not None:
|
|
entity.is_pinned = is_pinned
|
|
|
|
return await self._repo.update(planned_leave_day_id, entity)
|
|
|
|
# -- bulk apply (POST /api/plan/apply) --------------------------------
|
|
#
|
|
# This intentionally does NOT reuse `add_planned_day` for the write path.
|
|
# `add_planned_day`'s double-booking check
|
|
# (`list_by_workspace_child(..., from_date=date, to_date=date)`) looks at
|
|
# *any* parent planned on that child+date, not just the one being added,
|
|
# so calling it once per incoming day would misfire on legitimate
|
|
# multi-parent days (e.g. dubbeldagar, where both parents are
|
|
# deliberately planned on the same date) and would abort a re-apply of an
|
|
# already-identical plan instead of treating it as a no-op. A bulk apply
|
|
# also needs to validate the *whole* batch before writing anything ("a
|
|
# half-applied plan is far worse than a rejected one"), which a
|
|
# one-day-at-a-time call cannot do. `validate_dubbeldagar` on
|
|
# `AnalyticsService` *is* reused below, since that check (15-month window)
|
|
# is legitimately per-day and stateless.
|
|
|
|
async def apply_proposed_days(
|
|
self,
|
|
workspace_id: UUID,
|
|
days: list[ProposedDayInput],
|
|
replace: bool = False,
|
|
delete_keys: set[tuple[UUID, UUID, date]] | None = None,
|
|
) -> PlanApplyResult:
|
|
"""Persist a batch of proposed days as `planned_leave_day` rows.
|
|
|
|
Idempotent per (workspace, child, parent, date): re-applying an
|
|
identical batch creates nothing and is reported as all
|
|
`skipped_identical_count`. Identity here is `day_type`/`is_dubbeldag`
|
|
only — a day whose only difference from what's persisted is
|
|
`wish_id`/`is_pinned` still counts as `skipped_identical_count`, but
|
|
is quietly written as a metadata-only update rather than left stale;
|
|
it is never treated as a collision. A date already planned
|
|
*differently* (in `day_type`/`is_dubbeldag`) is a collision — by
|
|
default the whole batch is refused and nothing is written; pass
|
|
`replace=True` to overwrite those specific collisions (every other
|
|
collision-free day in the batch is written too).
|
|
|
|
`delete_keys` names rows (by their `(child_id, parent_personal_
|
|
data_uuid, date)` identity) to delete as part of this same call —
|
|
e.g. `sync_plan` uses it for rows the freshly-computed plan no longer
|
|
accounts for at all. A key must not also appear in `days`; that is a
|
|
structural error (a batch cannot both write and delete the same
|
|
slot). Deletions are folded into the same up-front validation as the
|
|
creates/replaces: the tier (or dubbeldagar slot) a deleted row frees
|
|
is credited back before checking whether the batch's new demand
|
|
fits, so a sync that frees N days and re-spends N days in the same
|
|
call is validated against the *post-sync* balance, not a stale
|
|
pre-write snapshot. A key with no matching row (already gone) is
|
|
silently ignored rather than treated as an error.
|
|
|
|
Validates the entire batch up front (day types, per-day dubbeldagar
|
|
window, batch-wide dubbeldagar quota, child eligibility window,
|
|
remaining sjukpenning/lagsta balance, credited for anything in
|
|
`delete_keys`) and only writes if everything passes. Stages 2-4 below
|
|
(the classification read through the final write) run inside one
|
|
`self._uow()` unit of work — a `BEGIN IMMEDIATE` transaction on the
|
|
shared connection, serialized against any other unit of work via a
|
|
per-connection lock (see `app.db.connection.unit_of_work`). That lock
|
|
+ transaction is what actually keeps a rejection, a crash mid-batch,
|
|
or a concurrent apply/sync on the same workspace from ever leaving
|
|
partial writes behind; the up-front validate-then-write split alone
|
|
only ever protected against a *rejected* batch writing anything, not
|
|
a crashed or interleaved one.
|
|
"""
|
|
delete_keys = delete_keys or set()
|
|
|
|
# -- stage 1: structural validation ------------------------------
|
|
structural_errors: list[Rejection] = []
|
|
for i, d in enumerate(days):
|
|
if d.day_type not in _PERSISTABLE_DAY_TYPES and d.day_type != "free":
|
|
structural_errors.append(
|
|
Rejection(
|
|
message_key="planApply.rejection.invalidDayType",
|
|
data={"index": i, "date": d.date.isoformat(), "day_type": d.day_type},
|
|
)
|
|
)
|
|
|
|
persistable = [d for d in days if d.day_type != "free"]
|
|
skipped_free_count = len(days) - len(persistable)
|
|
|
|
deduped: dict[tuple[UUID, UUID, date], ProposedDayInput] = {}
|
|
for d in persistable:
|
|
key = (d.child_id, d.parent_personal_data_uuid, d.date)
|
|
prior = deduped.get(key)
|
|
# Conflict is keyed on day_type/is_dubbeldag only — the same
|
|
# (child, parent, date) appearing twice with different `wish_id`/
|
|
# `is_pinned` is not a genuine conflict (attribution metadata,
|
|
# not the persisted day's identity); the later entry simply wins.
|
|
if prior is not None and (
|
|
prior.day_type != d.day_type or prior.is_dubbeldag != d.is_dubbeldag
|
|
):
|
|
structural_errors.append(
|
|
Rejection(
|
|
message_key="planApply.rejection.conflictingDuplicate",
|
|
data={
|
|
"date": d.date.isoformat(),
|
|
"child_id": str(d.child_id),
|
|
"parent_personal_data_uuid": str(d.parent_personal_data_uuid),
|
|
},
|
|
)
|
|
)
|
|
deduped[key] = d
|
|
|
|
for key in delete_keys:
|
|
if key in deduped:
|
|
child_id, parent_uuid, day = key
|
|
structural_errors.append(
|
|
Rejection(
|
|
message_key="planApply.rejection.deleteKeyConflict",
|
|
data={
|
|
"date": day.isoformat(),
|
|
"child_id": str(child_id),
|
|
"parent_personal_data_uuid": str(parent_uuid),
|
|
},
|
|
)
|
|
)
|
|
|
|
if structural_errors:
|
|
return PlanApplyResult(accepted=False, rejections=structural_errors)
|
|
|
|
unique_days = list(deduped.values())
|
|
if not unique_days and not delete_keys:
|
|
return PlanApplyResult(accepted=True, skipped_free_count=skipped_free_count)
|
|
|
|
# Stages 2-4 run inside one unit of work: a concurrent apply/sync for
|
|
# this same workspace blocks at `BEGIN IMMEDIATE` (inside `self._uow()`)
|
|
# until this call commits or rolls back, then re-reads `existing_by_key`
|
|
# against the now-committed state instead of racing this call's reads.
|
|
async with self._uow():
|
|
# -- stage 2: classify against existing rows (collision detection) --
|
|
by_child: dict[UUID, list[date]] = {}
|
|
for d in unique_days:
|
|
by_child.setdefault(d.child_id, []).append(d.date)
|
|
for child_id, _parent_uuid, day in delete_keys:
|
|
by_child.setdefault(child_id, []).append(day)
|
|
|
|
existing_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {}
|
|
for child_id, dates in by_child.items():
|
|
existing_rows = await self._repo.list_by_workspace_child(
|
|
workspace_id=workspace_id,
|
|
child_id=child_id,
|
|
from_date=min(dates),
|
|
to_date=max(dates),
|
|
)
|
|
for row in existing_rows:
|
|
existing_by_key[(row.child_id, row.parent_personal_data_uuid, row.date)] = row
|
|
|
|
# Rows named by `delete_keys` that actually exist — a key with no
|
|
# matching row (already gone) is silently ignored.
|
|
delete_rows: list[PlannedLeaveDay] = [
|
|
existing_by_key[key] for key in delete_keys if key in existing_by_key
|
|
]
|
|
|
|
to_create: list[ProposedDayInput] = []
|
|
to_replace: list[tuple[PlannedLeaveDay, ProposedDayInput]] = []
|
|
to_update_metadata: list[tuple[PlannedLeaveDay, ProposedDayInput]] = []
|
|
collisions: list[DayCollision] = []
|
|
skipped_identical_count = 0
|
|
|
|
for d in unique_days:
|
|
key = (d.child_id, d.parent_personal_data_uuid, d.date)
|
|
existing = existing_by_key.get(key)
|
|
if existing is None:
|
|
to_create.append(d)
|
|
continue
|
|
if existing.day_type == d.day_type and existing.is_dubbeldag == d.is_dubbeldag:
|
|
skipped_identical_count += 1
|
|
# Identical-vs-collision is (and stays) keyed on
|
|
# day_type/is_dubbeldag only. A row whose only difference is
|
|
# `wish_id`/`is_pinned` is never a collision requiring
|
|
# `replace=True` — it's a plain metadata update, written
|
|
# below without touching `skipped_identical_count` or
|
|
# `collisions`.
|
|
if existing.wish_id != d.wish_id or existing.is_pinned != d.is_pinned:
|
|
to_update_metadata.append((existing, d))
|
|
continue
|
|
collisions.append(
|
|
DayCollision(
|
|
date=d.date,
|
|
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
|
child_id=d.child_id,
|
|
existing_day_type=existing.day_type,
|
|
proposed_day_type=d.day_type,
|
|
)
|
|
)
|
|
if replace:
|
|
to_replace.append((existing, d))
|
|
|
|
if collisions and not replace:
|
|
return PlanApplyResult(
|
|
accepted=False,
|
|
collisions=collisions,
|
|
rejections=[
|
|
Rejection(
|
|
message_key="planApply.rejection.collisionsNeedReplace",
|
|
data={"count": len(collisions)},
|
|
)
|
|
],
|
|
)
|
|
|
|
pending = to_create + [new for _, new in to_replace]
|
|
if not pending and not to_update_metadata and not delete_rows:
|
|
# Nothing left to write — either an empty batch or a fully
|
|
# idempotent re-apply.
|
|
return PlanApplyResult(
|
|
accepted=True,
|
|
skipped_identical_count=skipped_identical_count,
|
|
skipped_free_count=skipped_free_count,
|
|
)
|
|
|
|
# -- stage 3: domain validation on the batch that would actually write
|
|
#
|
|
# `delete_rows` is threaded into both checks below so a row this same
|
|
# call is about to delete is credited back to its tier/quota BEFORE
|
|
# the new demand is checked against what's left — the bug this
|
|
# `delete_keys` parameter exists to fix was validating against a
|
|
# pre-write snapshot that hadn't yet had that capacity freed.
|
|
domain_errors: list[str] = []
|
|
domain_errors.extend(
|
|
await self._validate_dubbeldagar_batch(pending, to_replace, delete_rows)
|
|
)
|
|
domain_errors.extend(await self._validate_eligible_range(pending))
|
|
domain_errors.extend(
|
|
await self._validate_remaining_balance(pending, to_replace, delete_rows)
|
|
)
|
|
domain_errors.extend(
|
|
await self._validate_remaining_vacation_balance(pending, to_replace, delete_rows)
|
|
)
|
|
|
|
if domain_errors:
|
|
return PlanApplyResult(
|
|
accepted=False, collisions=collisions, rejections=domain_errors
|
|
)
|
|
|
|
# -- stage 4: write, batched (H1: one executemany per operation
|
|
# instead of one commit per row) --------------------------------
|
|
delete_ids = [old.id for old, _ in to_replace] + [row.id for row in delete_rows]
|
|
if delete_ids:
|
|
await self._repo.delete_many(delete_ids)
|
|
|
|
now = datetime.now()
|
|
to_insert: list[PlannedLeaveDay] = [
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=workspace_id,
|
|
child_id=d.child_id,
|
|
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
|
date=d.date,
|
|
day_type=d.day_type,
|
|
is_dubbeldag=d.is_dubbeldag,
|
|
wish_id=d.wish_id,
|
|
is_pinned=d.is_pinned,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
for d in pending
|
|
]
|
|
if to_insert:
|
|
await self._repo.create_many(to_insert)
|
|
created = to_insert
|
|
|
|
if to_update_metadata:
|
|
metadata_pairs: list[tuple[UUID, UUID | None, bool]] = []
|
|
for existing, d in to_update_metadata:
|
|
existing.wish_id = d.wish_id
|
|
existing.is_pinned = d.is_pinned
|
|
metadata_pairs.append((existing.id, d.wish_id, d.is_pinned))
|
|
await self._repo.update_metadata_many(metadata_pairs)
|
|
|
|
return PlanApplyResult(
|
|
accepted=True,
|
|
created=created,
|
|
created_count=len(to_create),
|
|
skipped_identical_count=skipped_identical_count,
|
|
skipped_free_count=skipped_free_count,
|
|
replaced_count=len(to_replace),
|
|
deleted_count=len(delete_rows),
|
|
)
|
|
|
|
# -- sync (POST /api/plan/sync) ---------------------------------------
|
|
#
|
|
# Recomputes and persists a workspace's plan in one step, then reports
|
|
# what changed. Runs `PlanningEngineService.propose(..., only_pinned_
|
|
# existing=True)`, which hides every *unpinned* wish-attributed
|
|
# `planned_leave_day` row from the allocator (see that flag's docstring
|
|
# in `allocation_service.py`) so the auto-planned portion of the plan is
|
|
# re-derived from scratch each call — this is what lets a re-plan
|
|
# legitimately move or retype previously-auto-placed days, while a
|
|
# pinned day (fed to the allocator unchanged, and debited off the top by
|
|
# `_Ledger._reserve_pinned_days`) can never be reassigned or overwritten.
|
|
#
|
|
# Pinned days therefore never appear in `proposal.days` in the first
|
|
# place (the allocator never emits anything for an already-occupied
|
|
# key) -- "no collision ever arises" is a property of the allocator, not
|
|
# something this method has to special-case in the write path. The one
|
|
# extra thing this method computes beyond `proposal.days` is which rows
|
|
# fall out of the freshly-computed plan entirely (a wish's window moved
|
|
# away from a date it used to occupy, or the wish was deleted) --
|
|
# `apply_proposed_days` only ever creates/replaces/updates keys present
|
|
# in its own batch, so nothing else would identify those. Those keys are
|
|
# handed to `apply_proposed_days` as `delete_keys` rather than deleted
|
|
# here directly, so the capacity they free is credited back during that
|
|
# same call's up-front validation — deleting them first (or after) would
|
|
# let a sync that frees N days and re-spends N days read the freed days
|
|
# as still-spent net-new demand and reject the whole batch.
|
|
|
|
async def sync_plan(
|
|
self,
|
|
workspace_id: UUID,
|
|
today: date | None = None,
|
|
eviction_choice: str | None = None,
|
|
) -> PlanSyncResult:
|
|
"""Propose, persist, and diff — the single call the direct-
|
|
|
|
manipulation calendar UX needs (no more separate propose/apply
|
|
steps). The diff (`changes`) is computed from a pre-write snapshot
|
|
read here, before any write, compared against what's actually
|
|
persisted afterwards — never reconstructed from the client side or
|
|
guessed from `apply_proposed_days`'s own (coarser) counters.
|
|
|
|
**Eviction preview/resolution.** Not every stale key (see the module
|
|
comment above) is a silent-data-loss bug: a wish the user just
|
|
edited to claim fewer/different dates sheds its OWN old rows as
|
|
'removed' `changes` — expected, and applied immediately with no
|
|
friction. The bug this parameter fixes is different: a stale row
|
|
whose wish was NOT touched, still wants exactly what it always
|
|
wanted, but lost the priority race for a shared pool to some OTHER
|
|
wish's edit. `_genuinely_evicted_rows` tells the two apart by
|
|
checking, for each stale row, whether ITS OWN wish's fresh
|
|
`WindowOutcome` for that date now reports `shortfall_days > 0` — the
|
|
wish still wants that capacity and didn't get it, as opposed to no
|
|
longer wanting it at all.
|
|
|
|
When nothing is genuinely evicted, this call behaves exactly as
|
|
before (no extra round trip, no extra friction — requirement 5).
|
|
When something would be genuinely evicted:
|
|
- `eviction_choice is None` (the default): nothing is written at
|
|
all, not even the harmless parts of the batch — "never evict
|
|
without an explicit instruction". The returned `eviction_preview`
|
|
names what ran out (`ResourceShortage`) and exactly which days
|
|
would go (`EvictionCandidate`), so the caller can ask the user.
|
|
- `eviction_choice` names one of the two caller-authorized
|
|
strategies (`EVICTION_CHOICE_DROP_FUTURE_PL` /
|
|
`EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR`): only the
|
|
genuinely-evicted rows matching that category (future PL days /
|
|
this calendar year's vacation days) are added to `delete_keys`,
|
|
on top of the always-deleted self-caused stale rows. If that
|
|
doesn't free enough (e.g. the real shortage was the OTHER
|
|
category), `apply_proposed_days`' own balance validation rejects
|
|
the whole batch exactly as it would have anyway — atomic, nothing
|
|
partially written.
|
|
|
|
Pinned days are structurally excluded from `stale_keys` itself (see
|
|
below), so no eviction strategy can ever reach one.
|
|
"""
|
|
if self._engine is None:
|
|
raise ValidationError("PlanningService.sync_plan requires a PlanningEngineService")
|
|
resolved_today = today or date.today()
|
|
|
|
# One unit of work spans the pre-write snapshot through the post-write
|
|
# re-read, not just `apply_proposed_days`'s own writes — otherwise a
|
|
# concurrent sync could commit between this call's `before` read and
|
|
# its (nested, no-op-passthrough — see `unit_of_work`'s docstring)
|
|
# write, and this call's `changes` diff would be computed against a
|
|
# snapshot that was already stale by the time it read `before`.
|
|
async with self._uow():
|
|
before = await self._repo.list(workspace_id=workspace_id)
|
|
before_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {
|
|
(d.child_id, d.parent_personal_data_uuid, d.date): d for d in before
|
|
}
|
|
pinned_keys = {key for key, d in before_by_key.items() if d.is_pinned}
|
|
|
|
proposal = await self._engine.propose(
|
|
workspace_id, today=today, only_pinned_existing=True
|
|
)
|
|
|
|
batch = [
|
|
ProposedDayInput(
|
|
date=d.date,
|
|
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
|
child_id=d.child_id,
|
|
day_type=d.day_type,
|
|
is_dubbeldag=d.is_dubbeldag,
|
|
wish_id=d.wish_id,
|
|
is_pinned=False,
|
|
)
|
|
for d in proposal.days
|
|
if d.day_type != "free"
|
|
and d.child_id is not None
|
|
# Defense-in-depth only: the allocator never proposes anything
|
|
# for an already-occupied key (pinned rows stay in
|
|
# `existing_planned` — see `only_pinned_existing` above), so this
|
|
# should never actually filter anything out.
|
|
and (d.child_id, d.parent_personal_data_uuid, d.date) not in pinned_keys
|
|
]
|
|
|
|
batch_keys = {(d.child_id, d.parent_personal_data_uuid, d.date) for d in batch}
|
|
# Rows the freshly-computed plan no longer accounts for at all:
|
|
# unpinned, wish-attributed, and absent from this run's batch — e.g.
|
|
# the wish was deleted, or its window moved elsewhere. Passed as
|
|
# `delete_keys` so `apply_proposed_days` validates the deletions
|
|
# together with the creates/replaces (see the comment above this
|
|
# method) rather than deleting them itself afterwards.
|
|
stale_keys = {
|
|
key
|
|
for key, row in before_by_key.items()
|
|
if not row.is_pinned and row.wish_id is not None and key not in batch_keys
|
|
}
|
|
stale_rows = [before_by_key[key] for key in stale_keys]
|
|
|
|
# Genuinely evicted: a stale row whose OWN wish still wants that
|
|
# exact capacity (fresh `shortfall_days > 0`) but lost it to a
|
|
# different wish's edit — see this method's docstring. Every
|
|
# other stale row is "self-caused" (the wish itself moved/
|
|
# shrank/was retargeted) and is always deleted, exactly like
|
|
# before this parameter existed.
|
|
evicted_rows = self._genuinely_evicted_rows(stale_rows, proposal.outcomes)
|
|
eviction_preview = await self._build_eviction_preview(workspace_id, evicted_rows)
|
|
|
|
if not evicted_rows:
|
|
delete_keys = stale_keys
|
|
elif eviction_choice is None:
|
|
return PlanSyncResult(
|
|
proposal=proposal,
|
|
apply_result=PlanApplyResult(
|
|
accepted=False,
|
|
rejections=[
|
|
Rejection(
|
|
message_key="planSync.rejection.evictionChoiceRequired",
|
|
data={"count": len(evicted_rows)},
|
|
)
|
|
],
|
|
),
|
|
changes=[],
|
|
eviction_preview=eviction_preview,
|
|
)
|
|
else:
|
|
evicted_keys = {self._row_key(row) for row in evicted_rows}
|
|
self_caused_keys = stale_keys - evicted_keys
|
|
authorized_keys = self._eviction_delete_keys(
|
|
evicted_rows, eviction_choice, resolved_today
|
|
)
|
|
delete_keys = self_caused_keys | authorized_keys
|
|
|
|
apply_result = await self.apply_proposed_days(
|
|
workspace_id=workspace_id, days=batch, replace=True, delete_keys=delete_keys
|
|
)
|
|
|
|
changes: list[PlanChange] = []
|
|
if apply_result.accepted:
|
|
after = await self._repo.list(workspace_id=workspace_id)
|
|
after_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {
|
|
(d.child_id, d.parent_personal_data_uuid, d.date): d for d in after
|
|
}
|
|
|
|
for key in sorted(
|
|
set(before_by_key) | set(after_by_key),
|
|
key=lambda k: (k[2], str(k[0]), str(k[1])),
|
|
):
|
|
child_id, parent_uuid, d = key
|
|
old = before_by_key.get(key)
|
|
new = after_by_key.get(key)
|
|
if old is None and new is not None:
|
|
changes.append(
|
|
PlanChange(
|
|
kind="added",
|
|
date=d,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
child_id=child_id,
|
|
old_day_type=None,
|
|
new_day_type=new.day_type,
|
|
old_is_dubbeldag=None,
|
|
new_is_dubbeldag=new.is_dubbeldag,
|
|
old_wish_id=None,
|
|
new_wish_id=new.wish_id,
|
|
)
|
|
)
|
|
elif old is not None and new is None:
|
|
changes.append(
|
|
PlanChange(
|
|
kind="removed",
|
|
date=d,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
child_id=child_id,
|
|
old_day_type=old.day_type,
|
|
new_day_type=None,
|
|
old_is_dubbeldag=old.is_dubbeldag,
|
|
new_is_dubbeldag=None,
|
|
old_wish_id=old.wish_id,
|
|
new_wish_id=None,
|
|
)
|
|
)
|
|
elif old is not None and new is not None:
|
|
if old.day_type != new.day_type or old.is_dubbeldag != new.is_dubbeldag:
|
|
changes.append(
|
|
PlanChange(
|
|
kind="retyped",
|
|
date=d,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
child_id=child_id,
|
|
old_day_type=old.day_type,
|
|
new_day_type=new.day_type,
|
|
old_is_dubbeldag=old.is_dubbeldag,
|
|
new_is_dubbeldag=new.is_dubbeldag,
|
|
old_wish_id=old.wish_id,
|
|
new_wish_id=new.wish_id,
|
|
)
|
|
)
|
|
elif old.wish_id != new.wish_id:
|
|
changes.append(
|
|
PlanChange(
|
|
kind="reattributed",
|
|
date=d,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
child_id=child_id,
|
|
old_day_type=old.day_type,
|
|
new_day_type=new.day_type,
|
|
old_is_dubbeldag=old.is_dubbeldag,
|
|
new_is_dubbeldag=new.is_dubbeldag,
|
|
old_wish_id=old.wish_id,
|
|
new_wish_id=new.wish_id,
|
|
)
|
|
)
|
|
|
|
return PlanSyncResult(
|
|
proposal=proposal,
|
|
apply_result=apply_result,
|
|
changes=changes,
|
|
eviction_preview=eviction_preview,
|
|
)
|
|
|
|
@staticmethod
|
|
def _row_key(row: PlannedLeaveDay) -> tuple[UUID, UUID, date]:
|
|
return (row.child_id, row.parent_personal_data_uuid, row.date)
|
|
|
|
@staticmethod
|
|
def _genuinely_evicted_rows(
|
|
stale_rows: list[PlannedLeaveDay], outcomes: list[WindowOutcome]
|
|
) -> list[PlannedLeaveDay]:
|
|
"""Split "genuinely evicted" (blame a different wish) from
|
|
|
|
"self-caused" (the row's own wish moved/shrank and no longer wants
|
|
it) — see `sync_plan`'s docstring. A stale row is genuinely evicted
|
|
only when its own `wish_id` has a fresh `WindowOutcome` covering
|
|
that exact date (`start_date <= row.date <= end_date`) that reports
|
|
`shortfall_days > 0`: the wish still wants that day and didn't get
|
|
it. No matching outcome at all (the wish's window moved away from
|
|
this date entirely) is always self-caused, never eviction.
|
|
"""
|
|
outcomes_by_wish: dict[UUID, list[WindowOutcome]] = {}
|
|
for outcome in outcomes:
|
|
outcomes_by_wish.setdefault(outcome.wish_id, []).append(outcome)
|
|
|
|
evicted: list[PlannedLeaveDay] = []
|
|
for row in stale_rows:
|
|
if row.wish_id is None:
|
|
continue # defensive: stale_keys already excludes these
|
|
matching = [
|
|
o
|
|
for o in outcomes_by_wish.get(row.wish_id, [])
|
|
if o.start_date <= row.date <= o.end_date
|
|
]
|
|
if any(o.shortfall_days > 0 for o in matching):
|
|
evicted.append(row)
|
|
return evicted
|
|
|
|
async def _build_eviction_preview(
|
|
self, workspace_id: UUID, evicted_rows: list[PlannedLeaveDay]
|
|
) -> EvictionPreview:
|
|
"""Shape `evicted_rows` into the (candidates, shortages) pair the
|
|
|
|
frontend renders the warning + day list from. Deterministic
|
|
ordering (date descending, tie-broken on parent/child/day_type)
|
|
regardless of the set-iteration order `evicted_rows` arrived in.
|
|
"""
|
|
if not evicted_rows:
|
|
return _NO_EVICTION_PREVIEW
|
|
|
|
wish_by_id: dict[UUID, str] = {}
|
|
if self._leave_wish_repo is not None:
|
|
wishes = await self._leave_wish_repo.get_by_workspace(workspace_id)
|
|
wish_by_id = {w.id: w.name for w in wishes}
|
|
|
|
ordered = sorted(
|
|
evicted_rows,
|
|
key=lambda r: (
|
|
r.date,
|
|
str(r.parent_personal_data_uuid),
|
|
str(r.child_id),
|
|
r.day_type,
|
|
),
|
|
reverse=True,
|
|
)
|
|
|
|
candidates = [
|
|
EvictionCandidate(
|
|
date=row.date,
|
|
parent_personal_data_uuid=row.parent_personal_data_uuid,
|
|
child_id=row.child_id,
|
|
day_type=row.day_type,
|
|
is_dubbeldag=row.is_dubbeldag,
|
|
wish_id=row.wish_id,
|
|
wish_name=wish_by_id.get(row.wish_id),
|
|
)
|
|
for row in ordered
|
|
# `evicted_rows` structurally always has a non-null `wish_id`
|
|
# (`_genuinely_evicted_rows` skips ad-hoc rows) -- this filter is
|
|
# defense-in-depth, and narrows the type for the constructor call
|
|
# below rather than needing a `# type: ignore`.
|
|
if row.wish_id is not None
|
|
]
|
|
|
|
shortage_counts: dict[tuple[str, UUID, str | None, UUID | None, int | None], int] = {}
|
|
shortage_order: list[tuple[str, UUID, str | None, UUID | None, int | None]] = []
|
|
for row in ordered:
|
|
if row.day_type in _PL_DAY_TYPES:
|
|
key = (
|
|
"parental_leave",
|
|
row.parent_personal_data_uuid,
|
|
row.day_type,
|
|
row.child_id,
|
|
None,
|
|
)
|
|
else:
|
|
key = ("vacation", row.parent_personal_data_uuid, None, None, row.date.year)
|
|
if key not in shortage_counts:
|
|
shortage_order.append(key)
|
|
shortage_counts[key] = shortage_counts.get(key, 0) + 1
|
|
|
|
shortages = [
|
|
ResourceShortage(
|
|
resource_kind=key[0],
|
|
parent_personal_data_uuid=key[1],
|
|
tier=key[2],
|
|
child_id=key[3],
|
|
year=key[4],
|
|
days_evicted=shortage_counts[key],
|
|
)
|
|
for key in shortage_order
|
|
]
|
|
|
|
return EvictionPreview(would_evict=True, candidates=candidates, shortages=shortages)
|
|
|
|
@staticmethod
|
|
def _eviction_delete_keys(
|
|
evicted_rows: list[PlannedLeaveDay], eviction_choice: str, today: date
|
|
) -> set[tuple[UUID, UUID, date]]:
|
|
"""The subset of `evicted_rows` the caller's chosen strategy authorizes
|
|
|
|
deleting — never more than what's genuinely evicted, and never a
|
|
pinned row (structurally impossible here: `evicted_rows` is always a
|
|
subset of `stale_keys`, which already excludes pinned rows).
|
|
"""
|
|
if eviction_choice == EVICTION_CHOICE_DROP_FUTURE_PL:
|
|
authorized = [
|
|
r for r in evicted_rows if r.day_type in _PL_DAY_TYPES and r.date >= today
|
|
]
|
|
elif eviction_choice == EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR:
|
|
authorized = [
|
|
r for r in evicted_rows if r.day_type == "vacation" and r.date.year == today.year
|
|
]
|
|
else:
|
|
authorized = []
|
|
return {PlanningService._row_key(r) for r in authorized}
|
|
|
|
async def _validate_dubbeldagar_batch(
|
|
self,
|
|
pending: list[ProposedDayInput],
|
|
to_replace: list[tuple[PlannedLeaveDay, ProposedDayInput]],
|
|
delete_rows: list[PlannedLeaveDay],
|
|
) -> list[Rejection]:
|
|
"""Per-day window check (reused from AnalyticsService) + batch-wide quota.
|
|
|
|
`AnalyticsService.validate_dubbeldagar` only sees rows already
|
|
committed to the DB, so it cannot by itself catch a batch that
|
|
introduces more than `max_dubbeldagar` new dubbeldagar in one go —
|
|
that aggregate check is done here, on top of it. `delete_rows`
|
|
(rows this same call is about to delete, via `delete_keys`) free
|
|
their dubbeldagar slot just like a `to_replace` old row does, so
|
|
they're credited back the same way when projecting the total.
|
|
"""
|
|
errors: list[Rejection] = []
|
|
dubbeldag_days = [d for d in pending if d.is_dubbeldag]
|
|
if not dubbeldag_days:
|
|
return errors
|
|
|
|
for d in dubbeldag_days:
|
|
try:
|
|
await self._analytics.validate_dubbeldagar(d.child_id, d.date)
|
|
except DubbeldagError as exc:
|
|
message_key = exc.message_key or "planApply.rejection.dubbeldagInvalid"
|
|
data: dict[str, object] = {**exc.data, "date": d.date.isoformat()}
|
|
errors.append(Rejection(message_key=message_key, data=data))
|
|
|
|
replaced_out_dubbeldag_by_child: dict[UUID, int] = {}
|
|
for old, _new in to_replace:
|
|
if old.is_dubbeldag:
|
|
replaced_out_dubbeldag_by_child[old.child_id] = (
|
|
replaced_out_dubbeldag_by_child.get(old.child_id, 0) + 1
|
|
)
|
|
for row in delete_rows:
|
|
if row.is_dubbeldag:
|
|
replaced_out_dubbeldag_by_child[row.child_id] = (
|
|
replaced_out_dubbeldag_by_child.get(row.child_id, 0) + 1
|
|
)
|
|
|
|
by_child: dict[UUID, int] = {}
|
|
for d in dubbeldag_days:
|
|
by_child[d.child_id] = by_child.get(d.child_id, 0) + 1
|
|
|
|
for child_id, new_count in by_child.items():
|
|
already = await self._repo.count_dubbeldagar(child_id)
|
|
replaced_out = replaced_out_dubbeldag_by_child.get(child_id, 0)
|
|
projected_total = already - replaced_out + new_count
|
|
if projected_total > settings.rules.max_dubbeldagar:
|
|
errors.append(
|
|
Rejection(
|
|
message_key="planApply.rejection.dubbeldagBatchQuotaExceeded",
|
|
data={
|
|
"child_id": str(child_id),
|
|
"projected_total": projected_total,
|
|
"max_dubbeldagar": settings.rules.max_dubbeldagar,
|
|
},
|
|
)
|
|
)
|
|
return errors
|
|
|
|
async def _validate_eligible_range(self, pending: list[ProposedDayInput]) -> list[Rejection]:
|
|
"""Every date must fall within the child's eligible window.
|
|
|
|
Lower bound is the child's birth date; upper bound is the hard
|
|
expiry (`LeaveRules.hard_expiry_age_years`, default 12) after which
|
|
no FP days may be used at all, per Swedish law.
|
|
"""
|
|
errors: list[Rejection] = []
|
|
birth_dates: dict[UUID, date] = {}
|
|
|
|
for d in pending:
|
|
if d.child_id not in birth_dates:
|
|
projection = await self._analytics.get_days_left_at_age_4(
|
|
d.parent_personal_data_uuid, d.child_id
|
|
)
|
|
birth_dates[d.child_id] = projection.birth_date
|
|
|
|
birth_date = birth_dates[d.child_id]
|
|
hard_expiry = birth_date + relativedelta(years=settings.rules.hard_expiry_age_years)
|
|
if d.date < birth_date or d.date > hard_expiry:
|
|
errors.append(
|
|
Rejection(
|
|
message_key="planApply.rejection.outsideEligibleWindow",
|
|
data={
|
|
"date": d.date.isoformat(),
|
|
"child_id": str(d.child_id),
|
|
"birth_date": birth_date.isoformat(),
|
|
"hard_expiry": hard_expiry.isoformat(),
|
|
},
|
|
)
|
|
)
|
|
return errors
|
|
|
|
async def _tier_pool_left(self, parent_uuid: UUID, child_id: UUID) -> tuple[int, int]:
|
|
"""Remaining (sjukpenning_left, lagsta_left), net of already-planned rows.
|
|
|
|
Mirrors the pool computation in
|
|
`allocation_service.AllocationInputLoader.load` (same FK-snapshot-or-
|
|
rule-default, minus locally committed days) so this check agrees with
|
|
what the planning engine itself would have allowed.
|
|
"""
|
|
rules = settings.rules
|
|
latest = await self._parental_leave_repo.get_latest_by_parent(parent_uuid, child_id)
|
|
if latest is not None:
|
|
sjuk_total_left = latest.tier_data.sjukpenning_left
|
|
lagsta_total_left = latest.tier_data.lagsta_niva_left
|
|
else:
|
|
# No FK snapshot yet: default this parent's share via the shared
|
|
# helper — see `default_tier_pool_share` docstring (Bug A fix:
|
|
# this and `AllocationInputLoader.load` used to disagree, 390/90
|
|
# vs. 195/45). Parent count follows the child's actual workspace
|
|
# (solo parent gets the full pool, not half of it).
|
|
parent_count = await self._analytics.get_parent_count_for_child(child_id)
|
|
sjuk_total_left, lagsta_total_left = default_tier_pool_share(rules, parent_count)
|
|
|
|
committed_sjuk = await self._repo.count_by_parent_type(parent_uuid, child_id, "sjukpenning")
|
|
committed_lagsta = await self._repo.count_by_parent_type(parent_uuid, child_id, "lagsta")
|
|
return (
|
|
max(sjuk_total_left - committed_sjuk, 0),
|
|
max(lagsta_total_left - committed_lagsta, 0),
|
|
)
|
|
|
|
async def _validate_remaining_balance(
|
|
self,
|
|
pending: list[ProposedDayInput],
|
|
to_replace: list[tuple[PlannedLeaveDay, ProposedDayInput]],
|
|
delete_rows: list[PlannedLeaveDay],
|
|
) -> list[Rejection]:
|
|
"""The batch must not spend more sjukpenning/lagsta days than remain.
|
|
|
|
Per (parent, child, tier): net_new = (new days of that tier the batch
|
|
adds) - (days of that tier being freed: replaced away, via
|
|
`to_replace`, or deleted outright via `delete_rows`/`delete_keys`).
|
|
Rejects if net_new exceeds what's left. Crediting `delete_rows` here
|
|
is what lets a `sync_plan` call that frees N days and re-spends N
|
|
days elsewhere pass validation against the balance *after* those
|
|
deletions, instead of rejecting against a stale pre-write snapshot.
|
|
"""
|
|
errors: list[Rejection] = []
|
|
pairs = {(d.parent_personal_data_uuid, d.child_id) for d in pending}
|
|
|
|
for parent_uuid, child_id in pairs:
|
|
sjuk_left, lagsta_left = await self._tier_pool_left(parent_uuid, child_id)
|
|
for tier, left in (("sjukpenning", sjuk_left), ("lagsta", lagsta_left)):
|
|
added = sum(
|
|
1
|
|
for d in pending
|
|
if d.parent_personal_data_uuid == parent_uuid
|
|
and d.child_id == child_id
|
|
and d.day_type == tier
|
|
)
|
|
removed = sum(
|
|
1
|
|
for old, new in to_replace
|
|
if new.parent_personal_data_uuid == parent_uuid
|
|
and new.child_id == child_id
|
|
and old.day_type == tier
|
|
) + sum(
|
|
1
|
|
for row in delete_rows
|
|
if row.parent_personal_data_uuid == parent_uuid
|
|
and row.child_id == child_id
|
|
and row.day_type == tier
|
|
)
|
|
net_new = added - removed
|
|
if net_new > left:
|
|
errors.append(
|
|
Rejection(
|
|
message_key="planApply.rejection.insufficientBalance",
|
|
data={
|
|
"parent_personal_data_uuid": str(parent_uuid),
|
|
"child_id": str(child_id),
|
|
"tier": tier,
|
|
"needed": net_new,
|
|
"remaining": left,
|
|
},
|
|
)
|
|
)
|
|
return errors
|
|
|
|
async def _vacation_days_left(self, parent_uuid: UUID, year: int) -> int:
|
|
"""Remaining vacation (semester) days for one parent in one calendar year.
|
|
|
|
Vacation is budgeted per (parent, calendar year) — unlike the FP
|
|
tiers above, which are per (parent, child) — because a semesterlagen
|
|
entitlement doesn't care which child a day off is attributed to.
|
|
|
|
Mirrors `AllocationInputLoader.load`'s vacation-budget derivation in
|
|
`allocation_service.py` exactly: `available = total_days +
|
|
saved_days_in - already_used`, where `total_days`/`saved_days_in`
|
|
come from the `VacationDays` row for (parent, year) if one exists.
|
|
A year with **no** row does NOT mean zero entitlement — the loader
|
|
defaults `total_days` to `rules.statutory_vacation_days_per_year`
|
|
(25) and flags the result `is_assumed=True`; this does the same
|
|
default (there is no `is_assumed` to carry here since this is a
|
|
plain remaining-days count, not a `VacationBudget`).
|
|
`PersonalData.min_vacation_days_per_year` is deliberately NOT used
|
|
here: per `ParentFacts.min_vacation_days_per_year`'s docstring and
|
|
the allocator's own carry-forward remedy, it is a semesterlagen
|
|
floor on days that must be *taken* each year (bounding what may be
|
|
saved forward), not a cap on how much may be spent — turning it into
|
|
a spending cap here would reject perfectly legal plans.
|
|
|
|
Like `_tier_pool_left` above, "already used" counts every currently
|
|
persisted 'vacation' row for the (parent, year), pinned or not — this
|
|
intentionally does NOT mirror the pure `_Ledger`'s pinned-day
|
|
exclusion (that exclusion exists there only because the `_Ledger`
|
|
debits a pinned day's cost itself in a separate step); this check has
|
|
no such separate step, so it must count everything actually
|
|
persisted, the same "whatever is committed reduces what's left"
|
|
shape the sjukpenning/lagsta check above already uses.
|
|
"""
|
|
rules = settings.rules
|
|
row = await self._vacation_repo.get_by_parent_year(parent_uuid, year)
|
|
if row is not None:
|
|
total_available = row.total_days + row.saved_days_in
|
|
else:
|
|
total_available = rules.statutory_vacation_days_per_year
|
|
|
|
committed = [
|
|
d
|
|
for d in await self._repo.list(parent_personal_data_uuid=parent_uuid)
|
|
if d.day_type == "vacation" and d.date.year == year
|
|
]
|
|
return max(total_available - len(committed), 0)
|
|
|
|
async def _validate_remaining_vacation_balance(
|
|
self,
|
|
pending: list[ProposedDayInput],
|
|
to_replace: list[tuple[PlannedLeaveDay, ProposedDayInput]],
|
|
delete_rows: list[PlannedLeaveDay],
|
|
) -> list[Rejection]:
|
|
"""The batch must not spend more vacation days than remain for a year.
|
|
|
|
Grouped by (parent, calendar year) rather than (parent, child, tier)
|
|
— see `_vacation_days_left`. Crediting `to_replace`/`delete_rows`
|
|
back before checking follows the exact same shape as
|
|
`_validate_remaining_balance` above, including for a period that
|
|
wraps New Year (e.g. Jullov, 20 Dec - 16 Jan): each `ProposedDayInput`
|
|
is grouped under its own `date.year`, so a band that crosses the
|
|
boundary is automatically split into two independently-checked
|
|
(parent, year) budgets — no special-casing needed here.
|
|
"""
|
|
errors: list[Rejection] = []
|
|
year_pairs = {
|
|
(d.parent_personal_data_uuid, d.date.year) for d in pending if d.day_type == "vacation"
|
|
}
|
|
|
|
for parent_uuid, year in year_pairs:
|
|
left = await self._vacation_days_left(parent_uuid, year)
|
|
added = sum(
|
|
1
|
|
for d in pending
|
|
if d.parent_personal_data_uuid == parent_uuid
|
|
and d.day_type == "vacation"
|
|
and d.date.year == year
|
|
)
|
|
removed = sum(
|
|
1
|
|
for old, new in to_replace
|
|
if new.parent_personal_data_uuid == parent_uuid
|
|
and old.day_type == "vacation"
|
|
and new.date.year == year
|
|
) + sum(
|
|
1
|
|
for row in delete_rows
|
|
if row.parent_personal_data_uuid == parent_uuid
|
|
and row.day_type == "vacation"
|
|
and row.date.year == year
|
|
)
|
|
net_new = added - removed
|
|
if net_new > left:
|
|
errors.append(
|
|
Rejection(
|
|
message_key="planApply.rejection.insufficientVacationBalance",
|
|
data={
|
|
"parent_personal_data_uuid": str(parent_uuid),
|
|
"year": year,
|
|
"needed": net_new,
|
|
"remaining": left,
|
|
},
|
|
)
|
|
)
|
|
return errors
|