Add a plan sync endpoint that recomputes, persists and reports the delta

The calendar is becoming the plan: a user edits a period and the schedule
updates immediately, with no separate propose and apply steps. POST
/api/plan/sync does both and returns a per-date diff against a snapshot taken
before any write, so the interface can say what actually moved rather than
guessing from coarse counters.

Pinned days now mean something. A day the user hand-edited keeps its type and
its parent: the loader hides unpinned wish-attributed rows from the allocator
so a re-plan is free to move them, while pinned rows stay visible and the
existing occupancy check refuses to touch them. Their cost is debited off the
pool before any window is funded, following the precedent already set for
preschool planning days.

Propose and apply are untouched. The allocator stays pure.
This commit is contained in:
2026-09-17 17:58:47 +02:00
parent 0e14b4dac8
commit 6cd60887d5
8 changed files with 863 additions and 17 deletions
+206
View File
@@ -10,6 +10,7 @@ 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.services.allocation_service import PlanningEngineService, PlanProposal
from app.services.analytics_service import AnalyticsService
from app.utils.errors import DubbeldagError, NotFoundError, ValidationError
@@ -87,16 +88,63 @@ class PlanApplyResult:
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,
planning_engine_service: PlanningEngineService | None = None,
) -> None:
self._repo = repo
self._analytics = analytics_service
self._parental_leave_repo = parental_leave_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,
@@ -413,6 +461,164 @@ class PlanningService:
replaced_count=len(to_replace),
)
# -- 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 step this method DOES own is deleting rows that 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 ever clean those up.
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
]
apply_result = await self.apply_proposed_days(
workspace_id=workspace_id, days=batch, replace=True
)
changes: list[PlanChange] = []
if apply_result.accepted:
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.
# `apply_proposed_days` never deletes a key it wasn't handed, so
# this is the one bit of write logic sync owns beyond it.
stale = [
row
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
]
for row in stale:
await self._repo.delete(row.id)
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],