d06c8c8449
Applied batches were validated against the remaining föräldrapenning tiers but never against semester. That held only while the allocator was the sole writer, since its ledger spends vacation against a per-year budget. The calendar is about to let a user force any day to semester and pin it, which writes straight past that ledger, so nothing would have stopped forty pinned vacation days against a twenty-five day entitlement. The check mirrors the allocator's own derivation rather than restating it: available days are total plus days saved in, and a year with no entered row falls back to the statutory entitlement instead of reading as zero. The semesterlagen floor stays out of it, being a bound on what may be carried forward rather than a cap on what may be spent. Vacation is budgeted per calendar year, so a period crossing New Year is charged to both.
972 lines
43 KiB
Python
972 lines
43 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.parental_leave_repository import ParentalLeaveRepository
|
|
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
|
from app.repositories.vacation_days_repository import VacationDaysRepository
|
|
from app.services.allocation_service import PlanningEngineService, PlanProposal
|
|
from app.services.analytics_service import AnalyticsService
|
|
from app.utils.errors import DubbeldagError, NotFoundError, ValidationError
|
|
|
|
# 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 PlanSyncResult:
|
|
proposal: PlanProposal
|
|
apply_result: PlanApplyResult
|
|
changes: list[PlanChange] = field(default_factory=list)
|
|
|
|
|
|
class PlanningService:
|
|
def __init__(
|
|
self,
|
|
repo: PlannedLeaveRepository,
|
|
analytics_service: AnalyticsService,
|
|
parental_leave_repo: ParentalLeaveRepository,
|
|
vacation_repo: VacationDaysRepository,
|
|
planning_engine_service: PlanningEngineService | 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
|
|
|
|
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) -> None:
|
|
"""Remove a planned leave day."""
|
|
entity = await self._repo.get_by_id(planned_leave_day_id)
|
|
if entity is None:
|
|
raise NotFoundError(f"PlannedLeaveDay {planned_leave_day_id} not found")
|
|
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) -> PlannedLeaveDay:
|
|
"""Get a planned day by ID."""
|
|
entity = await self._repo.get_by_id(planned_leave_day_id)
|
|
if entity is None:
|
|
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,
|
|
) -> PlannedLeaveDay:
|
|
"""Update a planned day (partial update)."""
|
|
entity = await self._repo.get_by_id(planned_leave_day_id)
|
|
if entity is None:
|
|
raise NotFoundError(f"PlannedLeaveDay {planned_leave_day_id} not found")
|
|
|
|
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 — SQLite gives us
|
|
no cross-request transaction here, so this explicit validate-then-
|
|
write split is what keeps a rejection from ever leaving partial
|
|
writes behind.
|
|
"""
|
|
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)
|
|
|
|
# -- 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 --------------------------------------------------
|
|
for old, _ in to_replace:
|
|
await self._repo.delete(old.id)
|
|
|
|
for row in delete_rows:
|
|
await self._repo.delete(row.id)
|
|
|
|
now = datetime.now()
|
|
created: list[PlannedLeaveDay] = []
|
|
for d in pending:
|
|
entity = 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,
|
|
)
|
|
created.append(await self._repo.create(entity))
|
|
|
|
for existing, d in to_update_metadata:
|
|
existing.wish_id = d.wish_id
|
|
existing.is_pinned = d.is_pinned
|
|
await self._repo.update(existing.id, existing)
|
|
|
|
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) -> 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.
|
|
"""
|
|
if self._engine is None:
|
|
raise ValidationError("PlanningService.sync_plan requires a PlanningEngineService")
|
|
|
|
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
|
|
}
|
|
|
|
apply_result = await self.apply_proposed_days(
|
|
workspace_id=workspace_id, days=batch, replace=True, delete_keys=stale_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)
|
|
|
|
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:
|
|
# `rules.sjukpenning_days`/`lagsta_niva_days` are the WHOLE
|
|
# CHILD's combined tier totals across both parents — halve them
|
|
# for this parent's default share, matching
|
|
# `AllocationInputLoader.load`'s no-snapshot fallback (Bug A
|
|
# fix: the two used to disagree, 390/90 vs. 195/45).
|
|
sjuk_total_left = rules.sjukpenning_days // 2
|
|
lagsta_total_left = rules.lagsta_niva_days // 2
|
|
|
|
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
|