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:
+12
-11
@@ -271,17 +271,6 @@ def get_analytics_service(
|
||||
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)]
|
||||
|
||||
|
||||
def get_planning_service(
|
||||
repo: PlannedLeaveRepositoryDep,
|
||||
analytics_service: AnalyticsServiceDep,
|
||||
parental_leave_repo: ParentalLeaveRepositoryDep,
|
||||
) -> PlanningService:
|
||||
return PlanningService(repo, analytics_service, parental_leave_repo)
|
||||
|
||||
|
||||
PlanningServiceDep = Annotated[PlanningService, Depends(get_planning_service)]
|
||||
|
||||
|
||||
def get_allocation_input_loader(
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
child_repo: ChildRepositoryDep,
|
||||
@@ -314,6 +303,18 @@ def get_planning_engine_service(loader: AllocationInputLoaderDep) -> PlanningEng
|
||||
PlanningEngineServiceDep = Annotated[PlanningEngineService, Depends(get_planning_engine_service)]
|
||||
|
||||
|
||||
def get_planning_service(
|
||||
repo: PlannedLeaveRepositoryDep,
|
||||
analytics_service: AnalyticsServiceDep,
|
||||
parental_leave_repo: ParentalLeaveRepositoryDep,
|
||||
planning_engine_service: PlanningEngineServiceDep,
|
||||
) -> PlanningService:
|
||||
return PlanningService(repo, analytics_service, parental_leave_repo, planning_engine_service)
|
||||
|
||||
|
||||
PlanningServiceDep = Annotated[PlanningService, Depends(get_planning_service)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Current-user dependency (JWT bearer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -15,6 +15,8 @@ from app.models.plan import (
|
||||
PlanApplyResponse,
|
||||
PlanProposalResponse,
|
||||
PlanProposeRequest,
|
||||
PlanSyncRequest,
|
||||
PlanSyncResponse,
|
||||
)
|
||||
from app.utils.errors import NotFoundError, ValidationError
|
||||
|
||||
@@ -55,3 +57,23 @@ async def apply_plan(payload: PlanApplyRequest, service: PlanningServiceDep) ->
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
return PlanApplyResponse.from_domain(result)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=PlanSyncResponse)
|
||||
async def sync_plan(payload: PlanSyncRequest, service: PlanningServiceDep) -> PlanSyncResponse:
|
||||
"""Recompute and persist a workspace's plan in one step.
|
||||
|
||||
The direct-manipulation calendar UX's one call: no separate preview
|
||||
step. Runs propose then apply internally (see
|
||||
`PlanningService.sync_plan`), never retyping or overwriting a day the
|
||||
user has pinned, and reports exactly what changed (`changes`) so the UI
|
||||
can show e.g. "Sommar 2028 flyttades 2 dagar — Ångra" instead of
|
||||
silently rewriting the calendar.
|
||||
"""
|
||||
try:
|
||||
result = await service.sync_plan(payload.workspace_id)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
|
||||
return PlanSyncResponse.from_domain(result)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.repositories_impl.sqlite.workspace_repository import (
|
||||
SQLiteWorkspaceInvitationRepository,
|
||||
SQLiteWorkspaceRepository,
|
||||
)
|
||||
from app.services.allocation_service import AllocationInputLoader, PlanningEngineService
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.child_service import ChildService
|
||||
@@ -262,10 +263,43 @@ async def analytics_service(
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def allocation_input_loader(
|
||||
workspace_repo: SQLiteWorkspaceRepository,
|
||||
child_repo: SQLiteChildRepository,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
vacation_repo: SQLiteVacationDaysRepository,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
day_override_repo: SQLiteDayOverrideRepository,
|
||||
) -> AllocationInputLoader:
|
||||
return AllocationInputLoader(
|
||||
workspace_repo=workspace_repo,
|
||||
child_repo=child_repo,
|
||||
personal_data_repo=personal_data_repo,
|
||||
parental_leave_repo=parental_leave_repo,
|
||||
vacation_repo=vacation_repo,
|
||||
planned_leave_repo=planned_leave_repo,
|
||||
leave_wish_repo=leave_wish_repo,
|
||||
day_override_repo=day_override_repo,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def planning_engine_service(
|
||||
allocation_input_loader: AllocationInputLoader,
|
||||
) -> PlanningEngineService:
|
||||
return PlanningEngineService(allocation_input_loader)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def planning_service(
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
analytics_service: AnalyticsService,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
planning_engine_service: PlanningEngineService,
|
||||
) -> PlanningService:
|
||||
return PlanningService(planned_leave_repo, analytics_service, parental_leave_repo)
|
||||
return PlanningService(
|
||||
planned_leave_repo, analytics_service, parental_leave_repo, planning_engine_service
|
||||
)
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Tests for `PlanningService.sync_plan` (POST /api/plan/sync).
|
||||
|
||||
Covers the Task 4 contract: propose+apply in one step, a pinned
|
||||
`planned_leave_day` is never retyped/overwritten by a re-plan even when the
|
||||
fresh proposal would have chosen differently for that date, the `changes`
|
||||
diff surfaces each of its four kinds, sync is idempotent on an unchanged
|
||||
plan, and `/propose` stays read-only (persists nothing).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
from app.entities.parental_leave import ParentalLeaveFetch, TierData
|
||||
from app.entities.personal_data import PersonalData
|
||||
from app.entities.planned_leave import PlannedLeaveDay
|
||||
from app.entities.vacation_days import VacationDays
|
||||
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
||||
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
|
||||
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
||||
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
||||
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
|
||||
from app.services.allocation_service import PlanningEngineService
|
||||
from app.services.planning_service import PlanningService
|
||||
from tests.conftest import SeededIds
|
||||
|
||||
|
||||
def _make_wish(workspace_id, child_id, **overrides) -> LeaveWish:
|
||||
base = dict(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
child_id=child_id,
|
||||
name="Test wish",
|
||||
window_start_month=2,
|
||||
window_start_day=1,
|
||||
window_end_month=2,
|
||||
window_end_day=7,
|
||||
target_weeks=1,
|
||||
recurrence="once",
|
||||
first_year=2027,
|
||||
last_year=None,
|
||||
priority=1,
|
||||
parent_mode="either",
|
||||
parent_personal_data_uuid=None,
|
||||
window_mode="fixed",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
base.update(overrides)
|
||||
return LeaveWish(**base)
|
||||
|
||||
|
||||
def _make_fetch(personal_data_uuid, workspace_id, child_id, fetched_at, sjuk_left, lagsta_left):
|
||||
tier = TierData(
|
||||
sjukpenning_total=390,
|
||||
sjukpenning_used=390 - sjuk_left,
|
||||
sjukpenning_left=sjuk_left,
|
||||
lagsta_niva_total=90,
|
||||
lagsta_niva_used=90 - lagsta_left,
|
||||
lagsta_niva_left=lagsta_left,
|
||||
)
|
||||
return ParentalLeaveFetch(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=personal_data_uuid,
|
||||
workspace_id=workspace_id,
|
||||
child_id=child_id,
|
||||
source="manual",
|
||||
fetched_at=fetched_at,
|
||||
total_days=480,
|
||||
days_used=480 - sjuk_left - lagsta_left,
|
||||
days_left=sjuk_left + lagsta_left,
|
||||
reserved_days_total=90,
|
||||
reserved_days_left=90,
|
||||
transferable_days_left=sjuk_left + lagsta_left,
|
||||
dubbeldagar_left=60,
|
||||
tier_data=tier,
|
||||
created_at=fetched_at,
|
||||
updated_at=fetched_at,
|
||||
)
|
||||
|
||||
|
||||
class TestSyncNeverOverwritesAPinnedDay:
|
||||
async def test_pinned_day_survives_even_though_fresh_plan_would_differ(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
vacation_repo: SQLiteVacationDaysRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
ws, child, parent = (
|
||||
seeded_ids.workspace_id,
|
||||
seeded_ids.child_id,
|
||||
seeded_ids.personal_data_uuid,
|
||||
)
|
||||
# No vacation budget this year -> the monthly-salaried parent's
|
||||
# tier-3 policy (vacation first) falls through to FP, sjukpenning
|
||||
# preferred -- so a free interior day in this window would be
|
||||
# funded as 'sjukpenning', never 'lagsta'.
|
||||
await vacation_repo.create(
|
||||
VacationDays(
|
||||
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
||||
)
|
||||
)
|
||||
await leave_wish_repo.create(_make_wish(ws, child))
|
||||
|
||||
pinned_date = date(2027, 2, 3) # a Wednesday inside the wish's window
|
||||
pinned = await planned_leave_repo.create(
|
||||
PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=ws,
|
||||
child_id=child,
|
||||
parent_personal_data_uuid=parent,
|
||||
date=pinned_date,
|
||||
day_type="lagsta", # deliberately NOT what a fresh plan would pick
|
||||
is_pinned=True,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
|
||||
assert result.apply_result.accepted is True
|
||||
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
pinned_after = next(r for r in rows if r.date == pinned_date)
|
||||
assert pinned_after.id == pinned.id
|
||||
assert pinned_after.day_type == "lagsta"
|
||||
assert pinned_after.is_pinned is True
|
||||
|
||||
# Every OTHER day in the window was free to be funded normally.
|
||||
other_days = [r for r in rows if r.date != pinned_date]
|
||||
assert other_days, "expected the rest of the window to be funded"
|
||||
assert all(r.day_type == "sjukpenning" for r in other_days)
|
||||
|
||||
# The pinned date never shows up as a changed slot.
|
||||
assert all(c.date != pinned_date for c in result.changes)
|
||||
|
||||
|
||||
class TestSyncChangeKinds:
|
||||
async def test_added_then_removed_when_the_wish_shrinks(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""A wish that still exists but now claims fewer dates: the dates it
|
||||
|
||||
drops (still `wish_id`-attributed, just no longer produced by this
|
||||
run's batch) must be reported (and written) as 'removed' -- distinct
|
||||
from a deleted wish, whose rows the DB itself detaches via
|
||||
`ON DELETE SET NULL` (making them ad-hoc, permanently kept) before
|
||||
`sync_plan` ever sees them.
|
||||
"""
|
||||
ws, child, _parent = (
|
||||
seeded_ids.workspace_id,
|
||||
seeded_ids.child_id,
|
||||
seeded_ids.personal_data_uuid,
|
||||
)
|
||||
wish = await leave_wish_repo.create(_make_wish(ws, child, name="A", window_end_day=7))
|
||||
|
||||
# -- first sync: pure 'added' (Feb 1-7) ---------------------------
|
||||
result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert result_1.apply_result.accepted is True
|
||||
assert result_1.changes, "expected at least one 'added' change"
|
||||
assert {c.kind for c in result_1.changes} == {"added"}
|
||||
|
||||
# -- idempotent re-sync: no changes -------------------------------
|
||||
result_2 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert result_2.apply_result.accepted is True
|
||||
assert result_2.changes == []
|
||||
|
||||
# -- shrink the window (Feb 1-4) -> the dropped tail is 'removed' -
|
||||
wish.window_end_day = 4
|
||||
await leave_wish_repo.update(wish.id, wish)
|
||||
result_3 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert result_3.apply_result.accepted is True
|
||||
assert result_3.changes, "expected 'removed' changes once the window shrinks"
|
||||
assert {c.kind for c in result_3.changes} == {"removed"}
|
||||
assert all(c.date > date(2027, 2, 4) for c in result_3.changes)
|
||||
|
||||
async def test_retyped_when_the_underlying_pool_changes(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
personal_data_repo: SQLitePersonalDataRepository,
|
||||
parental_leave_repo: SQLiteParentalLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
ws, child = seeded_ids.workspace_id, seeded_ids.child_id
|
||||
# An hourly-salaried parent prefers FP (sjukpenning first) over
|
||||
# vacation for tier-3 interior days, so this is deterministic
|
||||
# regardless of any assumed vacation default.
|
||||
parent = await personal_data_repo.create(
|
||||
PersonalData(uuid=uuid4(), workspace_id=ws, name="Hourly Parent", salary_type="hourly")
|
||||
)
|
||||
# `parent_mode="specific"` -- with a second (seeded) parent now also
|
||||
# in the workspace, an "either" wish would default to whichever
|
||||
# parent sorts first, not necessarily this one.
|
||||
await leave_wish_repo.create(
|
||||
_make_wish(ws, child, parent_mode="specific", parent_personal_data_uuid=parent.uuid)
|
||||
)
|
||||
await parental_leave_repo.create(
|
||||
_make_fetch(
|
||||
parent.uuid, ws, child, datetime(2027, 1, 1), sjuk_left=100, lagsta_left=100
|
||||
)
|
||||
)
|
||||
|
||||
result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert result_1.apply_result.accepted is True
|
||||
assert any(c.new_day_type == "sjukpenning" for c in result_1.changes)
|
||||
|
||||
# sjukpenning now exhausted -> the SAME dates must fall back to lagsta.
|
||||
await parental_leave_repo.create(
|
||||
_make_fetch(parent.uuid, ws, child, datetime(2027, 1, 2), sjuk_left=0, lagsta_left=100)
|
||||
)
|
||||
result_2 = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert result_2.apply_result.accepted is True
|
||||
assert result_2.changes, "expected 'retyped' changes once sjukpenning ran out"
|
||||
assert {c.kind for c in result_2.changes} == {"retyped"}
|
||||
assert all(
|
||||
c.old_day_type == "sjukpenning" and c.new_day_type == "lagsta" for c in result_2.changes
|
||||
)
|
||||
|
||||
async def test_reattributed_when_a_day_moves_to_a_different_wish(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
vacation_repo: SQLiteVacationDaysRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
ws, child, parent = (
|
||||
seeded_ids.workspace_id,
|
||||
seeded_ids.child_id,
|
||||
seeded_ids.personal_data_uuid,
|
||||
)
|
||||
await vacation_repo.create(
|
||||
VacationDays(
|
||||
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
|
||||
)
|
||||
)
|
||||
# `old_wish`'s own window doesn't cover `target_date` at all, so it
|
||||
# never competes for that key -- it just needs to be a real, still-
|
||||
# existing wish so the persisted row's `wish_id` stays non-null
|
||||
# (unlike deleting it, which `ON DELETE SET NULL` would turn into an
|
||||
# ad-hoc, permanently-kept row before `sync_plan` ever sees it).
|
||||
old_wish = await leave_wish_repo.create(
|
||||
_make_wish(
|
||||
ws,
|
||||
child,
|
||||
name="Old",
|
||||
window_start_month=3,
|
||||
window_start_day=1,
|
||||
window_end_month=3,
|
||||
window_end_day=7,
|
||||
)
|
||||
)
|
||||
new_wish = await leave_wish_repo.create(
|
||||
_make_wish(
|
||||
ws,
|
||||
child,
|
||||
name="New",
|
||||
window_start_month=2,
|
||||
window_start_day=3,
|
||||
window_end_month=2,
|
||||
window_end_day=3,
|
||||
target_days=1,
|
||||
)
|
||||
)
|
||||
target_date = date(2027, 2, 3)
|
||||
|
||||
# A row identical to what `new_wish` would compute for this date
|
||||
# (same date/type), but still attributed to `old_wish`.
|
||||
await planned_leave_repo.create(
|
||||
PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=ws,
|
||||
child_id=child,
|
||||
parent_personal_data_uuid=parent,
|
||||
date=target_date,
|
||||
day_type="sjukpenning",
|
||||
wish_id=old_wish.id,
|
||||
is_pinned=False,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
|
||||
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert result.apply_result.accepted is True
|
||||
by_date = {c.date: c for c in result.changes}
|
||||
assert target_date in by_date
|
||||
changed = by_date[target_date]
|
||||
assert changed.kind == "reattributed"
|
||||
assert changed.old_wish_id == old_wish.id
|
||||
assert changed.new_wish_id == new_wish.id
|
||||
assert changed.old_day_type == changed.new_day_type == "sjukpenning"
|
||||
|
||||
|
||||
class TestSyncIdempotent:
|
||||
async def test_unchanged_plan_produces_no_changes(
|
||||
self,
|
||||
planning_service: PlanningService,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
ws, child, _parent = (
|
||||
seeded_ids.workspace_id,
|
||||
seeded_ids.child_id,
|
||||
seeded_ids.personal_data_uuid,
|
||||
)
|
||||
await leave_wish_repo.create(_make_wish(ws, child))
|
||||
|
||||
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert first.apply_result.accepted is True
|
||||
|
||||
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
||||
assert second.apply_result.accepted is True
|
||||
assert second.changes == []
|
||||
|
||||
|
||||
class TestProposeStaysReadOnly:
|
||||
async def test_propose_never_writes_a_row(
|
||||
self,
|
||||
planning_engine_service: PlanningEngineService,
|
||||
planned_leave_repo: SQLitePlannedLeaveRepository,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
ws, child, _parent = (
|
||||
seeded_ids.workspace_id,
|
||||
seeded_ids.child_id,
|
||||
seeded_ids.personal_data_uuid,
|
||||
)
|
||||
await leave_wish_repo.create(_make_wish(ws, child))
|
||||
|
||||
proposal = await planning_engine_service.propose(ws, today=date(2027, 1, 1))
|
||||
assert proposal.days, "expected the proposal to compute something"
|
||||
|
||||
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
||||
assert rows == []
|
||||
@@ -1882,3 +1882,97 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
|
||||
# window's span) but never re-claims the fixed wish's date.
|
||||
assert date(2027, 3, 23) in flex_dates
|
||||
assert date(2027, 3, 25) not in flex_dates
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §Task 4 — pinned days: fixed, and debited off the top before any window
|
||||
# is funded (mirrors `_reserve_preschool_days`'s precedent).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPinnedDayIsNeverRetypedAndDebitsThePool:
|
||||
def test_pinned_day_keeps_its_type_and_the_pool_reflects_the_debit(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
pinned_date = date(2027, 2, 10) # a Wednesday inside the wish's window
|
||||
pinned = PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
child_id=child.id,
|
||||
parent_personal_data_uuid=parent.uuid,
|
||||
date=pinned_date,
|
||||
day_type="sjukpenning",
|
||||
is_pinned=True,
|
||||
)
|
||||
# A wish covering exactly this one date -- if the pinned day were
|
||||
# NOT fixed, this monthly-salaried parent's tier-3 policy (vacation
|
||||
# first) would fund it as 'vacation', not 'sjukpenning'.
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Single day",
|
||||
start=(2, 10),
|
||||
end=(2, 10),
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
planning_mode="solo",
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(5, 5, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
existing_planned=[pinned],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
# Never re-proposed / retyped for that date.
|
||||
assert not any(
|
||||
d.date == pinned_date and d.parent_personal_data_uuid == parent.uuid
|
||||
for d in proposal.days
|
||||
)
|
||||
# Its cost was debited off the top, before any window was funded --
|
||||
# the pool reflects one less sjukpenning day, not the untouched 5.
|
||||
pool_after = proposal.balances_after.pools_after[(parent.uuid, child.id)]
|
||||
assert pool_after.sjukpenning_left == 4
|
||||
assert pool_after.lagsta_left == 5
|
||||
|
||||
# The wish's own outcome still reports the pinned day as funded
|
||||
# (via cost_breakdown), just not as a newly-proposed day.
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.cost_breakdown["sjukpenning"] == 1
|
||||
assert outcome.shortfall_days == 0
|
||||
|
||||
def test_pinned_vacation_day_debits_the_vacation_budget(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
pinned_date = date(2027, 2, 10)
|
||||
pinned = PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
child_id=child.id,
|
||||
parent_personal_data_uuid=parent.uuid,
|
||||
date=pinned_date,
|
||||
day_type="vacation",
|
||||
is_pinned=True,
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
planning_mode="solo",
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(5, 5, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[],
|
||||
existing_planned=[pinned],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
budget_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
|
||||
assert budget_after.already_used == 1
|
||||
assert budget_after.available == 24
|
||||
|
||||
Reference in New Issue
Block a user