Files
F-r-ldraledigplannerare/backend/app/models/plan.py
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:33:03 +02:00

317 lines
10 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,
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
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
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,
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,
)
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 `wish_id`/`reason` (harmless if a caller sends them anyway —
a frontend can hand back `ProposedDayResponse` entries from `/propose`
verbatim; extra fields are ignored).
`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
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,
)
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],
)