6cd60887d5
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.
391 lines
13 KiB
Python
391 lines
13 KiB
Python
"""API schemas (Pydantic) for the planning-engine endpoints.
|
|
|
|
See PLANNING_ENGINE_SPEC.md §4. `POST /api/plan/propose` is read-only and
|
|
persists nothing — it just runs `PlanningEngineService.propose()` and shapes
|
|
the resulting (pure) `PlanProposal` for the wire.
|
|
|
|
`POST /api/plan/apply` is the other half: it takes a (possibly user-edited)
|
|
subset of a proposal's `days` and turns it into real `planned_leave_day`
|
|
rows via `PlanningService.apply_proposed_days`. See that method's docstring
|
|
for the idempotency/collision/`replace` semantics.
|
|
"""
|
|
|
|
from datetime import date
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.models.planned_leave import PlannedLeaveDayResponse
|
|
from app.services.allocation_service import PlanProposal
|
|
from app.services.planning_service import (
|
|
DayCollision,
|
|
PlanApplyResult,
|
|
PlanChange,
|
|
PlanSyncResult,
|
|
ProposedDayInput,
|
|
Rejection,
|
|
)
|
|
|
|
|
|
class PlanProposeRequest(BaseModel):
|
|
workspace_id: UUID
|
|
|
|
|
|
class ProposedDayResponse(BaseModel):
|
|
date: date
|
|
parent_personal_data_uuid: UUID
|
|
child_id: UUID | None
|
|
day_type: str
|
|
is_dubbeldag: bool
|
|
wish_id: UUID
|
|
reason: str
|
|
|
|
|
|
class ShortfallRemedyResponse(BaseModel):
|
|
kind: str
|
|
message_key: str
|
|
data: dict[str, object]
|
|
days_freed: int
|
|
|
|
|
|
class SurplusAdviceResponse(BaseModel):
|
|
"""§9.2 — the mirror of `ShortfallRemedyResponse`: a concrete suggestion
|
|
|
|
for a parent forecast to *forfeit* FP days at the age-4 boundary, rather
|
|
than run short. Same (kind, message_key/data, day-count) shape as the
|
|
remedy; `data` mirrors `PlanWarningResponse.data` for the (parent, child)
|
|
scope.
|
|
"""
|
|
|
|
kind: str
|
|
message_key: str
|
|
days_involved: int
|
|
data: dict[str, object]
|
|
|
|
|
|
class WindowOutcomeResponse(BaseModel):
|
|
wish_id: UUID
|
|
wish_name: str
|
|
# i18n provenance of `wish_name` — see `WindowOutcome.wish_name_message_key`
|
|
# (app/services/allocation_service.py). `None` for a free-typed/user-renamed
|
|
# wish, or any row predating this field.
|
|
wish_name_message_key: str | None
|
|
year: int
|
|
start_date: date
|
|
end_date: date
|
|
weeks_wished: int
|
|
weeks_funded: float
|
|
shortfall_days: int
|
|
strategy: str
|
|
message_key: str
|
|
data: dict[str, object]
|
|
cost_breakdown: dict[str, int]
|
|
remedies: list[ShortfallRemedyResponse]
|
|
|
|
|
|
class PlanWarningResponse(BaseModel):
|
|
kind: str
|
|
message_key: str
|
|
data: dict[str, object]
|
|
|
|
|
|
class PoolBalanceResponse(BaseModel):
|
|
parent_personal_data_uuid: UUID
|
|
child_id: UUID
|
|
sjukpenning_left: int
|
|
lagsta_left: int
|
|
dubbeldagar_left: int
|
|
|
|
|
|
class VacationBalanceResponse(BaseModel):
|
|
parent_personal_data_uuid: UUID
|
|
year: int
|
|
total_days: int
|
|
saved_days_in: int
|
|
already_used: int
|
|
# True when no `vacation_days` row existed for this (parent, year) and the
|
|
# loader defaulted `total_days` to the statutory entitlement rather than
|
|
# an entered value — see `VacationBudget.is_assumed`. The UI should use
|
|
# this to prompt the user to confirm/override the assumed year instead of
|
|
# silently treating it as a real entry.
|
|
is_assumed: bool
|
|
|
|
|
|
class Age4ProjectionResponse(BaseModel):
|
|
"""§3.3 part 2 — plan-aware age-4 projection: what this *proposed* plan
|
|
|
|
(not yet applied) implies at the child's 4th birthday, per (parent,
|
|
child). Always present, unlike `surplus_advice` which is only emitted
|
|
when there's a surplus to advise on.
|
|
"""
|
|
|
|
parent_personal_data_uuid: UUID
|
|
child_id: UUID
|
|
fourth_birthday: date
|
|
projected_left_at_b4: int
|
|
savable_after_b4: int
|
|
expiring_at_b4: int
|
|
savable_cap_at_b4: int
|
|
|
|
|
|
class PlanProposalResponse(BaseModel):
|
|
days: list[ProposedDayResponse]
|
|
outcomes: list[WindowOutcomeResponse]
|
|
warnings: list[PlanWarningResponse]
|
|
pool_balances: list[PoolBalanceResponse]
|
|
vacation_balances: list[VacationBalanceResponse]
|
|
surplus_advice: list[SurplusAdviceResponse]
|
|
age4_projections: list[Age4ProjectionResponse]
|
|
|
|
@classmethod
|
|
def from_domain(cls, proposal: PlanProposal) -> "PlanProposalResponse":
|
|
return cls(
|
|
days=[
|
|
ProposedDayResponse(
|
|
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,
|
|
reason=d.reason,
|
|
)
|
|
for d in proposal.days
|
|
],
|
|
outcomes=[
|
|
WindowOutcomeResponse(
|
|
wish_id=o.wish_id,
|
|
wish_name=o.wish_name,
|
|
wish_name_message_key=o.wish_name_message_key,
|
|
year=o.year,
|
|
start_date=o.start_date,
|
|
end_date=o.end_date,
|
|
weeks_wished=o.weeks_wished,
|
|
weeks_funded=o.weeks_funded,
|
|
shortfall_days=o.shortfall_days,
|
|
strategy=o.strategy,
|
|
message_key=o.message_key,
|
|
data=o.data,
|
|
cost_breakdown=o.cost_breakdown,
|
|
remedies=[
|
|
ShortfallRemedyResponse(
|
|
kind=r.kind,
|
|
message_key=r.message_key,
|
|
data=r.data,
|
|
days_freed=r.days_freed,
|
|
)
|
|
for r in o.remedies
|
|
],
|
|
)
|
|
for o in proposal.outcomes
|
|
],
|
|
warnings=[
|
|
PlanWarningResponse(kind=w.kind, message_key=w.message_key, data=w.data)
|
|
for w in proposal.warnings
|
|
],
|
|
pool_balances=[
|
|
PoolBalanceResponse(
|
|
parent_personal_data_uuid=parent_id,
|
|
child_id=child_id,
|
|
sjukpenning_left=pool.sjukpenning_left,
|
|
lagsta_left=pool.lagsta_left,
|
|
dubbeldagar_left=pool.dubbeldagar_left,
|
|
)
|
|
for (parent_id, child_id), pool in proposal.balances_after.pools_after.items()
|
|
],
|
|
vacation_balances=[
|
|
VacationBalanceResponse(
|
|
parent_personal_data_uuid=parent_id,
|
|
year=year,
|
|
total_days=budget.total_days,
|
|
saved_days_in=budget.saved_days_in,
|
|
already_used=budget.already_used,
|
|
is_assumed=budget.is_assumed,
|
|
)
|
|
for (parent_id, year), budget in proposal.balances_after.vacation_after.items()
|
|
],
|
|
surplus_advice=[
|
|
SurplusAdviceResponse(
|
|
kind=a.kind,
|
|
message_key=a.message_key,
|
|
days_involved=a.days_involved,
|
|
data=a.data,
|
|
)
|
|
for a in proposal.surplus_advice
|
|
],
|
|
age4_projections=[
|
|
Age4ProjectionResponse(
|
|
parent_personal_data_uuid=p.parent_personal_data_uuid,
|
|
child_id=p.child_id,
|
|
fourth_birthday=p.fourth_birthday,
|
|
projected_left_at_b4=p.projected_left_at_b4,
|
|
savable_after_b4=p.savable_after_b4,
|
|
expiring_at_b4=p.expiring_at_b4,
|
|
savable_cap_at_b4=p.savable_cap_at_b4,
|
|
)
|
|
for p in proposal.age4_projections
|
|
],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/plan/apply
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ProposedDayApplyItem(BaseModel):
|
|
"""One day to persist. Same shape as `ProposedDayResponse` minus the
|
|
explain-only `reason` (harmless if a caller sends it anyway — a frontend
|
|
can hand back `ProposedDayResponse` entries from `/propose` verbatim;
|
|
extra fields are ignored). `wish_id` DOES carry persistence meaning here
|
|
(unlike `reason`): it links the persisted row back to the wish that
|
|
produced it, so a client handing back a `/propose` day verbatim gets that
|
|
attribution persisted for free.
|
|
|
|
`day_type` additionally accepts 'free' (an automatic weekend/red-day
|
|
entry from a proposal) purely so a client doesn't have to filter those
|
|
out itself — they are always skipped, never persisted, see
|
|
`PlanApplyResponse.skipped_free_count`.
|
|
"""
|
|
|
|
date: date
|
|
parent_personal_data_uuid: UUID
|
|
child_id: UUID
|
|
day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation|free)$")
|
|
is_dubbeldag: bool = False
|
|
wish_id: UUID | None = None
|
|
is_pinned: bool = False
|
|
|
|
def to_domain(self) -> ProposedDayInput:
|
|
return ProposedDayInput(
|
|
date=self.date,
|
|
parent_personal_data_uuid=self.parent_personal_data_uuid,
|
|
child_id=self.child_id,
|
|
day_type=self.day_type,
|
|
is_dubbeldag=self.is_dubbeldag,
|
|
wish_id=self.wish_id,
|
|
is_pinned=self.is_pinned,
|
|
)
|
|
|
|
|
|
class PlanApplyRequest(BaseModel):
|
|
workspace_id: UUID
|
|
days: list[ProposedDayApplyItem]
|
|
replace: bool = False
|
|
|
|
|
|
class DayCollisionResponse(BaseModel):
|
|
date: date
|
|
parent_personal_data_uuid: UUID
|
|
child_id: UUID
|
|
existing_day_type: str
|
|
proposed_day_type: str
|
|
|
|
@classmethod
|
|
def from_domain(cls, collision: DayCollision) -> "DayCollisionResponse":
|
|
return cls(
|
|
date=collision.date,
|
|
parent_personal_data_uuid=collision.parent_personal_data_uuid,
|
|
child_id=collision.child_id,
|
|
existing_day_type=collision.existing_day_type,
|
|
proposed_day_type=collision.proposed_day_type,
|
|
)
|
|
|
|
|
|
class RejectionResponse(BaseModel):
|
|
"""§3.5 — a single reason a batch (or part of it) was refused, i18n-ready:
|
|
|
|
`message_key` + `data` (the `tips_service.Tip` shape) instead of a
|
|
pre-rendered string, so `PlanResults.tsx` can render it in the caller's
|
|
active locale.
|
|
"""
|
|
|
|
message_key: str
|
|
data: dict[str, object]
|
|
|
|
@classmethod
|
|
def from_domain(cls, rejection: Rejection) -> "RejectionResponse":
|
|
return cls(message_key=rejection.message_key, data=rejection.data)
|
|
|
|
|
|
class PlanApplyResponse(BaseModel):
|
|
accepted: bool
|
|
created_count: int
|
|
skipped_identical_count: int
|
|
skipped_free_count: int
|
|
replaced_count: int
|
|
collisions: list[DayCollisionResponse]
|
|
rejections: list[RejectionResponse]
|
|
created_days: list[PlannedLeaveDayResponse]
|
|
|
|
@classmethod
|
|
def from_domain(cls, result: PlanApplyResult) -> "PlanApplyResponse":
|
|
return cls(
|
|
accepted=result.accepted,
|
|
created_count=result.created_count,
|
|
skipped_identical_count=result.skipped_identical_count,
|
|
skipped_free_count=result.skipped_free_count,
|
|
replaced_count=result.replaced_count,
|
|
collisions=[DayCollisionResponse.from_domain(c) for c in result.collisions],
|
|
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],
|
|
)
|