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:
@@ -624,10 +624,38 @@ class _Ledger:
|
||||
self._existing_lookup
|
||||
)
|
||||
|
||||
self._reserve_pinned_days()
|
||||
self._reserve_preschool_days()
|
||||
|
||||
# -- setup ----------------------------------------------------------
|
||||
|
||||
def _reserve_pinned_days(self) -> None:
|
||||
"""§Task 4 — debit a pinned day's cost off the top, before any
|
||||
|
||||
window is funded, exactly like `_reserve_preschool_days` below
|
||||
reserves preschool closure days. A pinned `planned_leave_day` is a
|
||||
hard, already-spent fact regardless of which mode loaded this input
|
||||
(see `AllocationInputLoader.load`'s `only_pinned_existing`, which
|
||||
never nets a pinned row out of the pools/vacation budgets itself —
|
||||
this is the one place that happens). `_existing_lookup`/
|
||||
`_persisted_lookup` (built above from the same `existing_planned`)
|
||||
separately keep the Allocator from ever reassigning a pinned day's
|
||||
type or parent — this method only handles the resource debit.
|
||||
"""
|
||||
for p in self._input.existing_planned:
|
||||
if not p.is_pinned:
|
||||
continue
|
||||
if p.day_type == "vacation":
|
||||
self._spend_vacation(p.parent_personal_data_uuid, p.date)
|
||||
continue
|
||||
pool = self._pools.get((p.parent_personal_data_uuid, p.child_id))
|
||||
if pool is None:
|
||||
continue
|
||||
if p.day_type == "sjukpenning" and pool.sjukpenning_left > 0:
|
||||
pool.sjukpenning_left -= 1
|
||||
elif p.day_type == "lagsta" and pool.lagsta_left > 0:
|
||||
pool.lagsta_left -= 1
|
||||
|
||||
def _reserve_preschool_days(self) -> None:
|
||||
for child in self._input.children:
|
||||
reserve = _preschool_reserve_days(child, self._input.today, self._input.horizon_end)
|
||||
@@ -1854,7 +1882,27 @@ class AllocationInputLoader:
|
||||
self._leave_wish_repo = leave_wish_repo
|
||||
self._day_override_repo = day_override_repo
|
||||
|
||||
async def load(self, workspace_id: UUID, today: date | None = None) -> AllocationInput:
|
||||
async def load(
|
||||
self,
|
||||
workspace_id: UUID,
|
||||
today: date | None = None,
|
||||
*,
|
||||
only_pinned_existing: bool = False,
|
||||
) -> AllocationInput:
|
||||
"""Assemble the pure `AllocationInput`.
|
||||
|
||||
`only_pinned_existing` (§Task 4, see `PlanningEngineService.propose`)
|
||||
restricts `existing_planned` — and everything derived from it here
|
||||
(pool/vacation netting) — to rows that must stay immovable: pinned
|
||||
rows (`is_pinned`) and ad-hoc rows with no `wish_id`. Every other
|
||||
(unpinned, wish-attributed) row is left out entirely, so the
|
||||
allocator treats that date as open and re-derives it fresh. Pinned
|
||||
rows are *never* netted out of the pools/vacation budgets here even
|
||||
when `only_pinned_existing` is False — the pure `_Ledger` does that
|
||||
itself (`_reserve_pinned_days`), matching how it reserves preschool
|
||||
planning days off the top, so the debit happens in one place
|
||||
regardless of which mode loaded the input.
|
||||
"""
|
||||
today = today or date.today()
|
||||
|
||||
ws = await self._workspace_repo.get_by_id(workspace_id)
|
||||
@@ -1885,7 +1933,16 @@ class AllocationInputLoader:
|
||||
planned_for_child = await self._planned_leave_repo.list_by_workspace_child(
|
||||
workspace_id, child.id
|
||||
)
|
||||
existing_planned.extend(planned_for_child)
|
||||
if only_pinned_existing:
|
||||
# Hide unpinned, wish-attributed rows entirely -- see the
|
||||
# docstring above. Pinned rows and ad-hoc (no wish_id) rows
|
||||
# stay visible/immovable either way.
|
||||
visible_for_child = [
|
||||
d for d in planned_for_child if d.is_pinned or d.wish_id is None
|
||||
]
|
||||
else:
|
||||
visible_for_child = planned_for_child
|
||||
existing_planned.extend(visible_for_child)
|
||||
|
||||
for parent in parent_entities:
|
||||
latest = await self._parental_leave_repo.get_latest_by_parent(parent.uuid, child.id)
|
||||
@@ -1910,11 +1967,16 @@ class AllocationInputLoader:
|
||||
# Net out days already committed locally (planned_leave_day)
|
||||
# that an FK snapshot (if any) doesn't yet reflect, so the pure
|
||||
# allocator never double-books or over-spends a resource.
|
||||
# Pinned days are excluded here — the pure `_Ledger` debits
|
||||
# those itself (`_reserve_pinned_days`), so this loader-side
|
||||
# netting only ever covers non-pinned commitments (whichever
|
||||
# of those `visible_for_child` still includes).
|
||||
committed = [
|
||||
d
|
||||
for d in planned_for_child
|
||||
for d in visible_for_child
|
||||
if d.parent_personal_data_uuid == parent.uuid
|
||||
and d.day_type in ("sjukpenning", "lagsta")
|
||||
and not d.is_pinned
|
||||
]
|
||||
sjuk_committed = sum(1 for d in committed if d.day_type == "sjukpenning")
|
||||
lagsta_committed = sum(1 for d in committed if d.day_type == "lagsta")
|
||||
@@ -1928,12 +1990,16 @@ class AllocationInputLoader:
|
||||
horizon_end = _compute_horizon_end(today, children, wishes)
|
||||
|
||||
def _already_used_vacation(parent_uuid: UUID, year: int) -> int:
|
||||
# Pinned vacation days are excluded here for the same reason as
|
||||
# the FP pools above: `_Ledger._reserve_pinned_days` debits them
|
||||
# itself.
|
||||
return sum(
|
||||
1
|
||||
for d in existing_planned
|
||||
if d.parent_personal_data_uuid == parent_uuid
|
||||
and d.day_type == "vacation"
|
||||
and d.date.year == year
|
||||
and not d.is_pinned
|
||||
)
|
||||
|
||||
vacation: dict[tuple[UUID, int], VacationBudget] = {}
|
||||
@@ -2027,6 +2093,27 @@ class PlanningEngineService:
|
||||
self._loader = loader
|
||||
self._allocator = allocator or Allocator()
|
||||
|
||||
async def propose(self, workspace_id: UUID, today: date | None = None) -> PlanProposal:
|
||||
alloc_input = await self._loader.load(workspace_id, today=today)
|
||||
async def propose(
|
||||
self,
|
||||
workspace_id: UUID,
|
||||
today: date | None = None,
|
||||
*,
|
||||
only_pinned_existing: bool = False,
|
||||
) -> PlanProposal:
|
||||
"""Load fresh input and run the pure allocator over it.
|
||||
|
||||
`only_pinned_existing` (used by `PlanningService.sync_plan`, §Task 4)
|
||||
makes the loader hide every *unpinned*, wish-attributed
|
||||
`planned_leave_day` row from `AllocationInput.existing_planned` (and
|
||||
stop netting it out of the pools/vacation budgets) so the allocator
|
||||
re-derives that portion of the plan from scratch instead of treating
|
||||
it as an immovable fact — this is what lets a re-plan legitimately
|
||||
move/retype previously-auto-placed days. Pinned days and ad-hoc
|
||||
(`wish_id is None`) days are unaffected either way: always visible,
|
||||
always immovable. `POST /api/plan/propose` never sets this — it
|
||||
keeps its historical, unconditional behaviour.
|
||||
"""
|
||||
alloc_input = await self._loader.load(
|
||||
workspace_id, today=today, only_pinned_existing=only_pinned_existing
|
||||
)
|
||||
return self._allocator.propose(alloc_input)
|
||||
|
||||
Reference in New Issue
Block a user