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
+60
View File
@@ -20,6 +20,8 @@ from app.services.allocation_service import PlanProposal
from app.services.planning_service import (
DayCollision,
PlanApplyResult,
PlanChange,
PlanSyncResult,
ProposedDayInput,
Rejection,
)
@@ -328,3 +330,61 @@ class PlanApplyResponse(BaseModel):
rejections=[RejectionResponse.from_domain(r) for r in result.rejections],
created_days=[PlannedLeaveDayResponse.from_entity(d) for d in result.created],
)
# ---------------------------------------------------------------------------
# POST /api/plan/sync
# ---------------------------------------------------------------------------
class PlanSyncRequest(BaseModel):
workspace_id: UUID
class PlanChangeResponse(BaseModel):
"""One (child, parent, date) slot's delta from a `sync` call — see
`PlanChange`'s docstring for the `kind` vocabulary. Structured data only;
the frontend composes the toast copy ("Sommar 2028 flyttades 2 dagar —
Ångra") itself.
"""
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
@classmethod
def from_domain(cls, change: PlanChange) -> "PlanChangeResponse":
return cls(
kind=change.kind,
date=change.date,
parent_personal_data_uuid=change.parent_personal_data_uuid,
child_id=change.child_id,
old_day_type=change.old_day_type,
new_day_type=change.new_day_type,
old_is_dubbeldag=change.old_is_dubbeldag,
new_is_dubbeldag=change.new_is_dubbeldag,
old_wish_id=change.old_wish_id,
new_wish_id=change.new_wish_id,
)
class PlanSyncResponse(BaseModel):
proposal: PlanProposalResponse
apply_result: PlanApplyResponse
changes: list[PlanChangeResponse]
@classmethod
def from_domain(cls, result: PlanSyncResult) -> "PlanSyncResponse":
return cls(
proposal=PlanProposalResponse.from_domain(result.proposal),
apply_result=PlanApplyResponse.from_domain(result.apply_result),
changes=[PlanChangeResponse.from_domain(c) for c in result.changes],
)