Link planned days to their wish, fix the age-4 cap, drop the planning goal
Three changes that together unblock treating a calendar period as one editable object. Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL) so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a day the user hand-edited, and `leave_wish.target_days` so a period drawn on a calendar need not be a whole number of weeks. Apply-time identity still keys on day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update rather than a collision. The age-4 savable ceiling was applying the per-child figure of 96 to each parent's pool, modelling a two-parent family as able to save 192 days past the child's fourth birthday. `resolve_age4_savable_cap` is now the single place that decides 48 (couple with two parents) versus 96 (everyone else), and both AnalyticsService and the allocator read it. Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`. The allocator's tier-3 policy collapses to one rule; `salary_type` still branches, since that reflects what a vacation day actually costs a parent rather than a user preference.
This commit is contained in:
@@ -32,7 +32,13 @@ class LeaveRules(BaseModel):
|
||||
max_dubbeldagar: int = 60 # both parents, same child-day
|
||||
dubbeldagar_months_limit: int = 15 # only within the child's first 15 months
|
||||
|
||||
max_savable_days_at_age_4: int = 96 # age-4 ceiling
|
||||
max_savable_days_at_age_4: int = 96 # age-4 ceiling — per CHILD (pooled across parents)
|
||||
# Age-4 ceiling — per PARENT. The 96-day figure above is a per-child pool;
|
||||
# when a child has two parents, each parent's own savable balance is
|
||||
# capped at half of that (48), not 96 each (which would let a two-parent
|
||||
# family save 192 combined). `app.utils.tip_predicates.resolve_age4_savable_cap`
|
||||
# is the single place that decides which of the two applies.
|
||||
max_savable_days_per_parent_at_age_4: int = 48
|
||||
hard_expiry_age_years: int = 12
|
||||
|
||||
sgi_protected_first_months: int = 12
|
||||
|
||||
@@ -19,8 +19,6 @@ _FULL_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS workspace (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
planning_goal TEXT NOT NULL DEFAULT 'balanced'
|
||||
CHECK (planning_goal IN ('max_time','max_money','balanced')),
|
||||
planning_mode TEXT NOT NULL DEFAULT 'solo'
|
||||
CHECK (planning_mode IN ('solo','couple')),
|
||||
created_at TIMESTAMP NOT NULL
|
||||
@@ -91,6 +89,7 @@ CREATE TABLE IF NOT EXISTS leave_wish (
|
||||
window_end_day INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_end_day >= 1 AND window_end_day <= 31),
|
||||
target_weeks INTEGER NOT NULL,
|
||||
target_days INTEGER,
|
||||
recurrence TEXT NOT NULL DEFAULT 'once'
|
||||
CHECK (recurrence IN ('yearly','once')),
|
||||
first_year INTEGER NOT NULL DEFAULT 2026,
|
||||
@@ -99,8 +98,6 @@ CREATE TABLE IF NOT EXISTS leave_wish (
|
||||
parent_mode TEXT NOT NULL DEFAULT 'either'
|
||||
CHECK (parent_mode IN ('either','both','specific')),
|
||||
parent_personal_data_uuid TEXT,
|
||||
goal_override TEXT
|
||||
CHECK (goal_override IS NULL OR goal_override IN ('max_time','max_money','balanced')),
|
||||
window_mode TEXT NOT NULL DEFAULT 'fixed'
|
||||
CHECK (window_mode IN ('fixed','flexible')),
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
@@ -154,12 +151,15 @@ CREATE TABLE IF NOT EXISTS planned_leave_day (
|
||||
date DATE NOT NULL,
|
||||
day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning','lagsta','vacation')),
|
||||
is_dubbeldag INTEGER NOT NULL DEFAULT 0,
|
||||
wish_id TEXT,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(workspace_id, child_id, date, parent_personal_data_uuid),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE
|
||||
FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE,
|
||||
FOREIGN KEY (wish_id) REFERENCES leave_wish(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS day_override (
|
||||
|
||||
@@ -263,8 +263,9 @@ def get_analytics_service(
|
||||
planned_repo: PlannedLeaveRepositoryDep,
|
||||
child_repo: ChildRepositoryDep,
|
||||
pd_repo: PersonalDataRepositoryDep,
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
) -> AnalyticsService:
|
||||
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo)
|
||||
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
||||
|
||||
|
||||
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)]
|
||||
|
||||
@@ -41,10 +41,6 @@ class LeaveWish:
|
||||
parent_mode: str # 'either' | 'both' | 'specific'
|
||||
parent_personal_data_uuid: UUID | None # required when parent_mode == 'specific'
|
||||
|
||||
# Per-window override of workspace planning_goal. Nullable, no UI in v1 —
|
||||
# the field exists now so enabling it later needs no migration.
|
||||
goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None
|
||||
|
||||
# PLANNING_ENGINE_SPEC.md §10.1 — 'fixed' (default): window_start_*/window_end_*
|
||||
# are the exact placement, as above. 'flexible': the SAME four fields instead
|
||||
# describe the BOUNDING RANGE the allocator searches inside for the
|
||||
@@ -65,9 +61,19 @@ class LeaveWish:
|
||||
# revert to one.
|
||||
name_message_key: str | None = None
|
||||
|
||||
# A calendar-drawn period (e.g. dragging a band on the calendar UI) is not
|
||||
# a whole number of weeks. NULL (every row created before this field
|
||||
# existed, and any wish still specified in weeks) falls back to
|
||||
# `target_weeks * 7` — see `target_span_days()`.
|
||||
target_days: int | None = None
|
||||
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
def target_span_days(self) -> int:
|
||||
"""Wanted duration in days. `target_days` wins when set; otherwise weeks * 7."""
|
||||
return self.target_days if self.target_days is not None else self.target_weeks * 7
|
||||
|
||||
def window_wraps(self) -> bool:
|
||||
"""Whether (end_month, end_day) sorts before (start_month, start_day).
|
||||
|
||||
|
||||
@@ -14,5 +14,13 @@ class PlannedLeaveDay:
|
||||
date: date
|
||||
day_type: str # 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
is_dubbeldag: bool = False # True → counts against the 60-day dubbeldagar quota
|
||||
# Which `LeaveWish` produced this day (nullable: ad-hoc/legacy/unattributable
|
||||
# days have none). Lets the UI click a multi-day band on a calendar and edit
|
||||
# it as one period instead of one day at a time.
|
||||
wish_id: UUID | None = None
|
||||
# True once the user hand-edits this day. A later task makes the allocator
|
||||
# refuse to retype a pinned day on replan; this field only carries that
|
||||
# state through the stack so far.
|
||||
is_pinned: bool = False
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -9,6 +9,5 @@ from uuid import UUID
|
||||
class Workspace:
|
||||
id: UUID
|
||||
name: str
|
||||
planning_goal: str = "balanced" # 'max_time' | 'max_money' | 'balanced'
|
||||
planning_mode: str = "solo" # 'solo' | 'couple' — explicit, not inferred from member count
|
||||
created_at: datetime | None = None
|
||||
|
||||
@@ -19,6 +19,7 @@ class Age4ProjectionResponse(BaseModel):
|
||||
projected_left_at_b4: int
|
||||
savable_after_b4: int
|
||||
expiring_at_b4: int
|
||||
savable_cap_at_b4: int
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, p: Age4Projection) -> "Age4ProjectionResponse":
|
||||
@@ -33,6 +34,7 @@ class Age4ProjectionResponse(BaseModel):
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ class LeaveWishCreate(BaseModel):
|
||||
window_end_day: int = Field(ge=1, le=31)
|
||||
|
||||
target_weeks: int = Field(ge=1, le=52)
|
||||
# A calendar-drawn period is not a whole number of weeks. None (default)
|
||||
# falls back to `target_weeks * 7` — see `LeaveWish.target_span_days()`.
|
||||
target_days: int | None = Field(default=None, ge=1)
|
||||
recurrence: str = "once" # 'yearly' | 'once'
|
||||
first_year: int = Field(ge=2000, le=2200)
|
||||
last_year: int | None = Field(default=None, ge=2000, le=2200)
|
||||
@@ -38,8 +41,6 @@ class LeaveWishCreate(BaseModel):
|
||||
parent_mode: str = "either" # 'either' | 'both' | 'specific'
|
||||
parent_personal_data_uuid: UUID | None = None
|
||||
|
||||
goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None
|
||||
|
||||
# PLANNING_ENGINE_SPEC.md §10.1 — 'fixed': window_start_*/window_end_* are the
|
||||
# exact placement (default). 'flexible': the same fields describe the
|
||||
# bounding range the allocator searches within for the best-costing window.
|
||||
@@ -63,6 +64,10 @@ class LeaveWishUpdate(BaseModel):
|
||||
window_end_day: int | None = Field(None, ge=1, le=31)
|
||||
|
||||
target_weeks: int | None = Field(None, ge=1, le=52)
|
||||
# Present + null means "clear it" (fall back to target_weeks); absent
|
||||
# means "leave unchanged" — same convention as the other nullable fields
|
||||
# on this model (see `_clear_*` handling in `LeaveWishService.update`).
|
||||
target_days: int | None = Field(default=None, ge=1)
|
||||
recurrence: str | None = None
|
||||
first_year: int | None = Field(None, ge=2000, le=2200)
|
||||
last_year: int | None = Field(None, ge=2000, le=2200)
|
||||
@@ -71,8 +76,6 @@ class LeaveWishUpdate(BaseModel):
|
||||
parent_mode: str | None = None
|
||||
parent_personal_data_uuid: UUID | None = None
|
||||
|
||||
goal_override: str | None = None
|
||||
|
||||
window_mode: str | None = None # 'fixed' | 'flexible'
|
||||
|
||||
|
||||
@@ -89,6 +92,7 @@ class LeaveWishResponse(BaseModel):
|
||||
window_end_day: int
|
||||
|
||||
target_weeks: int
|
||||
target_days: int | None
|
||||
recurrence: str
|
||||
first_year: int
|
||||
last_year: int | None
|
||||
@@ -97,8 +101,6 @@ class LeaveWishResponse(BaseModel):
|
||||
parent_mode: str
|
||||
parent_personal_data_uuid: UUID | None
|
||||
|
||||
goal_override: str | None
|
||||
|
||||
window_mode: str
|
||||
|
||||
created_at: datetime | None
|
||||
@@ -117,13 +119,13 @@ class LeaveWishResponse(BaseModel):
|
||||
window_end_month=entity.window_end_month,
|
||||
window_end_day=entity.window_end_day,
|
||||
target_weeks=entity.target_weeks,
|
||||
target_days=entity.target_days,
|
||||
recurrence=entity.recurrence,
|
||||
first_year=entity.first_year,
|
||||
last_year=entity.last_year,
|
||||
priority=entity.priority,
|
||||
parent_mode=entity.parent_mode,
|
||||
parent_personal_data_uuid=entity.parent_personal_data_uuid,
|
||||
goal_override=entity.goal_override,
|
||||
window_mode=entity.window_mode,
|
||||
created_at=entity.created_at,
|
||||
updated_at=entity.updated_at,
|
||||
|
||||
@@ -123,6 +123,7 @@ class Age4ProjectionResponse(BaseModel):
|
||||
projected_left_at_b4: int
|
||||
savable_after_b4: int
|
||||
expiring_at_b4: int
|
||||
savable_cap_at_b4: int
|
||||
|
||||
|
||||
class PlanProposalResponse(BaseModel):
|
||||
@@ -218,6 +219,7 @@ class PlanProposalResponse(BaseModel):
|
||||
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
|
||||
],
|
||||
@@ -231,9 +233,12 @@ class PlanProposalResponse(BaseModel):
|
||||
|
||||
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).
|
||||
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
|
||||
@@ -246,6 +251,8 @@ class ProposedDayApplyItem(BaseModel):
|
||||
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(
|
||||
@@ -254,6 +261,8 @@ class ProposedDayApplyItem(BaseModel):
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ class PlannedLeaveDayCreate(BaseModel):
|
||||
date: date
|
||||
day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation)$")
|
||||
is_dubbeldag: bool = False
|
||||
# Which `LeaveWish` produced this day; None = ad-hoc.
|
||||
wish_id: UUID | None = None
|
||||
is_pinned: bool = False
|
||||
|
||||
|
||||
class PlannedLeaveDayUpdate(BaseModel):
|
||||
@@ -26,6 +29,8 @@ class PlannedLeaveDayUpdate(BaseModel):
|
||||
|
||||
day_type: str | None = Field(None, pattern="^(sjukpenning|lagsta|vacation)$")
|
||||
is_dubbeldag: bool | None = None
|
||||
wish_id: UUID | None = None
|
||||
is_pinned: bool | None = None
|
||||
|
||||
|
||||
class PlannedLeaveDayResponse(BaseModel):
|
||||
@@ -36,6 +41,8 @@ class PlannedLeaveDayResponse(BaseModel):
|
||||
date: date
|
||||
day_type: str
|
||||
is_dubbeldag: bool
|
||||
wish_id: UUID | None
|
||||
is_pinned: bool
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
@@ -49,6 +56,8 @@ class PlannedLeaveDayResponse(BaseModel):
|
||||
date=entity.date,
|
||||
day_type=entity.day_type,
|
||||
is_dubbeldag=entity.is_dubbeldag,
|
||||
wish_id=entity.wish_id,
|
||||
is_pinned=entity.is_pinned,
|
||||
created_at=entity.created_at,
|
||||
updated_at=entity.updated_at,
|
||||
)
|
||||
|
||||
@@ -15,14 +15,12 @@ class WorkspaceCreate(BaseModel):
|
||||
|
||||
class WorkspaceUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
planning_goal: str | None = None
|
||||
planning_mode: str | None = None
|
||||
|
||||
|
||||
class WorkspaceResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
planning_goal: str
|
||||
planning_mode: str
|
||||
created_at: datetime | None
|
||||
|
||||
@@ -31,7 +29,6 @@ class WorkspaceResponse(BaseModel):
|
||||
return cls(
|
||||
id=ws.id,
|
||||
name=ws.name,
|
||||
planning_goal=ws.planning_goal,
|
||||
planning_mode=ws.planning_mode,
|
||||
created_at=ws.created_at,
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
window_end_month=row["window_end_month"],
|
||||
window_end_day=row["window_end_day"],
|
||||
target_weeks=row["target_weeks"],
|
||||
target_days=row["target_days"],
|
||||
recurrence=row["recurrence"],
|
||||
first_year=row["first_year"],
|
||||
last_year=row["last_year"],
|
||||
@@ -42,7 +43,6 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
if row["parent_personal_data_uuid"] is not None
|
||||
else None
|
||||
),
|
||||
goal_override=row["goal_override"],
|
||||
window_mode=row["window_mode"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||
@@ -57,8 +57,8 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
INSERT INTO leave_wish
|
||||
(id, workspace_id, child_id, name, name_message_key,
|
||||
window_start_month, window_start_day, window_end_month, window_end_day,
|
||||
target_weeks, recurrence, first_year, last_year,
|
||||
priority, parent_mode, parent_personal_data_uuid, goal_override,
|
||||
target_weeks, target_days, recurrence, first_year, last_year,
|
||||
priority, parent_mode, parent_personal_data_uuid,
|
||||
window_mode, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
@@ -73,6 +73,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
item.window_end_month,
|
||||
item.window_end_day,
|
||||
item.target_weeks,
|
||||
item.target_days,
|
||||
item.recurrence,
|
||||
item.first_year,
|
||||
item.last_year,
|
||||
@@ -83,7 +84,6 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
if item.parent_personal_data_uuid is not None
|
||||
else None
|
||||
),
|
||||
item.goal_override,
|
||||
item.window_mode,
|
||||
item.created_at.isoformat(),
|
||||
item.updated_at.isoformat(),
|
||||
@@ -114,9 +114,9 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
SET child_id = ?, name = ?, name_message_key = ?,
|
||||
window_start_month = ?, window_start_day = ?,
|
||||
window_end_month = ?, window_end_day = ?,
|
||||
target_weeks = ?, recurrence = ?, first_year = ?, last_year = ?,
|
||||
target_weeks = ?, target_days = ?, recurrence = ?, first_year = ?, last_year = ?,
|
||||
priority = ?, parent_mode = ?, parent_personal_data_uuid = ?,
|
||||
goal_override = ?, window_mode = ?, updated_at = ?
|
||||
window_mode = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
@@ -128,6 +128,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
item.window_end_month,
|
||||
item.window_end_day,
|
||||
item.target_weeks,
|
||||
item.target_days,
|
||||
item.recurrence,
|
||||
item.first_year,
|
||||
item.last_year,
|
||||
@@ -138,7 +139,6 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
if item.parent_personal_data_uuid is not None
|
||||
else None
|
||||
),
|
||||
item.goal_override,
|
||||
item.window_mode,
|
||||
item.updated_at.isoformat(),
|
||||
str(id),
|
||||
|
||||
@@ -25,6 +25,8 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
|
||||
date=date.fromisoformat(row["date"]),
|
||||
day_type=row["day_type"],
|
||||
is_dubbeldag=bool(row["is_dubbeldag"]),
|
||||
wish_id=UUID(row["wish_id"]) if row["wish_id"] is not None else None,
|
||||
is_pinned=bool(row["is_pinned"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||
)
|
||||
@@ -37,8 +39,8 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
|
||||
"""
|
||||
INSERT INTO planned_leave_day
|
||||
(id, workspace_id, child_id, parent_personal_data_uuid, date,
|
||||
day_type, is_dubbeldag, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
day_type, is_dubbeldag, wish_id, is_pinned, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(item.id),
|
||||
@@ -48,6 +50,8 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
|
||||
item.date.isoformat(),
|
||||
item.day_type,
|
||||
int(item.is_dubbeldag),
|
||||
str(item.wish_id) if item.wish_id is not None else None,
|
||||
int(item.is_pinned),
|
||||
item.created_at.isoformat(),
|
||||
item.updated_at.isoformat(),
|
||||
),
|
||||
@@ -67,10 +71,17 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
|
||||
cursor = await self._conn.execute(
|
||||
"""
|
||||
UPDATE planned_leave_day
|
||||
SET day_type = ?, is_dubbeldag = ?, updated_at = ?
|
||||
SET day_type = ?, is_dubbeldag = ?, wish_id = ?, is_pinned = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(item.day_type, int(item.is_dubbeldag), item.updated_at.isoformat(), str(id)),
|
||||
(
|
||||
item.day_type,
|
||||
int(item.is_dubbeldag),
|
||||
str(item.wish_id) if item.wish_id is not None else None,
|
||||
int(item.is_pinned),
|
||||
item.updated_at.isoformat(),
|
||||
str(id),
|
||||
),
|
||||
)
|
||||
await self._conn.commit()
|
||||
if cursor.rowcount == 0:
|
||||
|
||||
@@ -24,7 +24,6 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
|
||||
return Workspace(
|
||||
id=UUID(row["id"]),
|
||||
name=row["name"],
|
||||
planning_goal=row["planning_goal"],
|
||||
planning_mode=row["planning_mode"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
)
|
||||
@@ -33,13 +32,12 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
|
||||
item.created_at = item.created_at or datetime.now()
|
||||
await self._conn.execute(
|
||||
"""
|
||||
INSERT INTO workspace (id, name, planning_goal, planning_mode, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO workspace (id, name, planning_mode, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(item.id),
|
||||
item.name,
|
||||
item.planning_goal,
|
||||
item.planning_mode,
|
||||
item.created_at.isoformat(),
|
||||
),
|
||||
@@ -54,8 +52,8 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
|
||||
|
||||
async def update(self, id: UUID, item: Workspace) -> Workspace:
|
||||
cursor = await self._conn.execute(
|
||||
"UPDATE workspace SET name = ?, planning_goal = ?, planning_mode = ? WHERE id = ?",
|
||||
(item.name, item.planning_goal, item.planning_mode, str(id)),
|
||||
"UPDATE workspace SET name = ?, planning_mode = ? WHERE id = ?",
|
||||
(item.name, item.planning_mode, str(id)),
|
||||
)
|
||||
await self._conn.commit()
|
||||
if cursor.rowcount == 0:
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.dependencies import (
|
||||
ParentalLeaveRepositoryDep,
|
||||
PersonalDataRepositoryDep,
|
||||
PlannedLeaveRepositoryDep,
|
||||
WorkspaceRepositoryDep,
|
||||
)
|
||||
from app.models.analytics import Age4ProjectionResponse, WorkspaceOverviewResponse
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
@@ -23,8 +24,9 @@ def _make_service(
|
||||
planned_repo: PlannedLeaveRepositoryDep,
|
||||
child_repo: ChildRepositoryDep,
|
||||
pd_repo: PersonalDataRepositoryDep,
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
) -> AnalyticsService:
|
||||
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo)
|
||||
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/overview", response_model=WorkspaceOverviewResponse)
|
||||
@@ -35,9 +37,10 @@ async def get_overview(
|
||||
planned_repo: PlannedLeaveRepositoryDep,
|
||||
child_repo: ChildRepositoryDep,
|
||||
pd_repo: PersonalDataRepositoryDep,
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
_current_user: CurrentUserDep,
|
||||
) -> WorkspaceOverviewResponse:
|
||||
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo)
|
||||
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
||||
try:
|
||||
overview = await service.get_overview(workspace_id, child_id)
|
||||
except NotFoundError as exc:
|
||||
@@ -53,9 +56,10 @@ async def get_projection(
|
||||
planned_repo: PlannedLeaveRepositoryDep,
|
||||
child_repo: ChildRepositoryDep,
|
||||
pd_repo: PersonalDataRepositoryDep,
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
_current_user: CurrentUserDep,
|
||||
) -> WorkspaceOverviewResponse:
|
||||
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo)
|
||||
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
||||
try:
|
||||
overview = await service.get_overview(workspace_id, child_id)
|
||||
except NotFoundError as exc:
|
||||
@@ -73,9 +77,10 @@ async def get_age4_projection(
|
||||
planned_repo: PlannedLeaveRepositoryDep,
|
||||
child_repo: ChildRepositoryDep,
|
||||
pd_repo: PersonalDataRepositoryDep,
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
_current_user: CurrentUserDep,
|
||||
) -> Age4ProjectionResponse:
|
||||
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo)
|
||||
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo, workspace_repo)
|
||||
try:
|
||||
proj = await service.get_days_left_at_age_4(personal_data_uuid, child_id)
|
||||
except NotFoundError as exc:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""HTTP routes for leave wishes.
|
||||
|
||||
Routers stay thin: validate via Pydantic, call the service, map domain errors
|
||||
to HTTP. Business logic (recurrence/parent_mode/goal_override validation,
|
||||
month-day range checks) lives in LeaveWishService. See PLANNING_ENGINE_SPEC.md §4.
|
||||
to HTTP. Business logic (recurrence/parent_mode validation, month-day range
|
||||
checks) lives in LeaveWishService. See PLANNING_ENGINE_SPEC.md §4.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
@@ -35,13 +35,13 @@ async def create_leave_wish(
|
||||
window_end_month=payload.window_end_month,
|
||||
window_end_day=payload.window_end_day,
|
||||
target_weeks=payload.target_weeks,
|
||||
target_days=payload.target_days,
|
||||
recurrence=payload.recurrence,
|
||||
first_year=payload.first_year,
|
||||
last_year=payload.last_year,
|
||||
priority=payload.priority,
|
||||
parent_mode=payload.parent_mode,
|
||||
parent_personal_data_uuid=payload.parent_personal_data_uuid,
|
||||
goal_override=payload.goal_override,
|
||||
window_mode=payload.window_mode,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
@@ -77,13 +77,13 @@ async def update_leave_wish(
|
||||
window_end_month=payload.window_end_month,
|
||||
window_end_day=payload.window_end_day,
|
||||
target_weeks=payload.target_weeks,
|
||||
target_days=payload.target_days,
|
||||
recurrence=payload.recurrence,
|
||||
first_year=payload.first_year,
|
||||
last_year=payload.last_year,
|
||||
priority=payload.priority,
|
||||
parent_mode=payload.parent_mode,
|
||||
parent_personal_data_uuid=payload.parent_personal_data_uuid,
|
||||
goal_override=payload.goal_override,
|
||||
window_mode=payload.window_mode,
|
||||
_clear_child_id="child_id" in fields_set and payload.child_id is None,
|
||||
_clear_last_year="last_year" in fields_set and payload.last_year is None,
|
||||
@@ -91,10 +91,10 @@ async def update_leave_wish(
|
||||
"parent_personal_data_uuid" in fields_set
|
||||
and payload.parent_personal_data_uuid is None
|
||||
),
|
||||
_clear_goal_override=("goal_override" in fields_set and payload.goal_override is None),
|
||||
_clear_name_message_key=(
|
||||
"name_message_key" in fields_set and payload.name_message_key is None
|
||||
),
|
||||
_clear_target_days="target_days" in fields_set and payload.target_days is None,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
|
||||
|
||||
@@ -81,9 +81,7 @@ async def update_workspace(
|
||||
_current_user: CurrentUserDep,
|
||||
) -> WorkspaceResponse:
|
||||
try:
|
||||
ws = await service.update_workspace(
|
||||
workspace_id, payload.name, payload.planning_goal, payload.planning_mode
|
||||
)
|
||||
ws = await service.update_workspace(workspace_id, payload.name, payload.planning_mode)
|
||||
except (NotFoundError, ValidationError) as exc:
|
||||
code = (
|
||||
status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -42,7 +42,11 @@ from app.utils.holidays import (
|
||||
cached_year_bounds,
|
||||
get_swedish_holidays,
|
||||
)
|
||||
from app.utils.tip_predicates import days_left_at_age_4_projection, has_vacation_near_expiry
|
||||
from app.utils.tip_predicates import (
|
||||
days_left_at_age_4_projection,
|
||||
has_vacation_near_expiry,
|
||||
resolve_age4_savable_cap,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reason codes (§3.5) — machine-readable so the UI can explain itself.
|
||||
@@ -106,7 +110,12 @@ MESSAGE_KEY_WINDOW_EXPLANATION = "planOutcomes.windowExplanation"
|
||||
_SUNDAY_HOLIDAY_NAME = SUNDAY_HOLIDAY_NAME
|
||||
_DUBBELDAGAR_MONTHS_LIMIT = settings.rules.dubbeldagar_months_limit
|
||||
_MAX_DUBBELDAGAR = settings.rules.max_dubbeldagar
|
||||
_MAX_SAVABLE_AT_B4 = settings.rules.max_savable_days_at_age_4
|
||||
# NOTE: no module-level "_MAX_SAVABLE_AT_B4" here on purpose — which age-4 cap
|
||||
# applies (96 per child vs. 48 per parent) depends on the workspace's
|
||||
# `planning_mode` and parent count, so it is resolved once per `propose()`
|
||||
# run by `AllocationInputLoader` onto `AllocationInput.age4_savable_cap` and
|
||||
# read from there (`self._input.age4_savable_cap`) everywhere `_Ledger` needs
|
||||
# it — never as a constant computed at import time.
|
||||
_SGI_MIN_DAYS_PER_WEEK = settings.rules.sgi_min_days_per_week_after_age_1
|
||||
# A "week off" costs ~5 spendable weekdays (Sat/Sun are already free) — see
|
||||
# §3.4: "a 6-week summer window is ~42 calendar days but only ~30 spendable
|
||||
@@ -178,8 +187,12 @@ class VacationBudget:
|
||||
class AllocationInput:
|
||||
today: date
|
||||
horizon_end: date
|
||||
planning_goal: str # 'max_time' | 'max_money' | 'balanced'
|
||||
planning_mode: str # 'solo' | 'couple'
|
||||
# The age-4 savable-days ceiling that applies to THIS workspace, resolved
|
||||
# once by the loader via `app.utils.tip_predicates.resolve_age4_savable_cap`
|
||||
# (96 per child, or 48 per parent for a couple workspace with 2 parents) —
|
||||
# never re-derived inside the (pure) `_Ledger`.
|
||||
age4_savable_cap: int
|
||||
children: list[ChildFacts]
|
||||
parents: list[ParentFacts]
|
||||
pools: dict[tuple[UUID, UUID], DayPool] # (parent, child) -> pool
|
||||
@@ -254,8 +267,10 @@ class Age4Projection:
|
||||
child_id: UUID
|
||||
fourth_birthday: date
|
||||
projected_left_at_b4: int
|
||||
savable_after_b4: int # min(projected_left_at_b4, max_savable_days_at_age_4)
|
||||
expiring_at_b4: int # max(projected_left_at_b4 - max_savable, 0) — LOST if not used
|
||||
savable_after_b4: int # min(projected_left_at_b4, savable_cap_at_b4)
|
||||
expiring_at_b4: int # max(projected_left_at_b4 - savable_cap_at_b4, 0) — LOST if not used
|
||||
# The cap actually applied — see `app.utils.tip_predicates.resolve_age4_savable_cap`.
|
||||
savable_cap_at_b4: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -733,7 +748,8 @@ class _Ledger:
|
||||
|
||||
def _maybe_clamp_age4(self, pool_key: tuple[UUID, UUID] | None, window_start: date) -> None:
|
||||
"""§9.1: from the 4th birthday onward, the FP pool is hard-capped at
|
||||
min(remaining, 96). Applied once per pool, the first time (in
|
||||
min(remaining, `self._input.age4_savable_cap`). Applied once per
|
||||
pool, the first time (in
|
||||
priority-then-chronological processing order) a window starting on or
|
||||
after the child's 4th birthday is funded from it.
|
||||
|
||||
@@ -754,7 +770,7 @@ class _Ledger:
|
||||
if pool is None or pool.clamped_at_b4:
|
||||
return
|
||||
remaining = pool.sjukpenning_left + pool.lagsta_left
|
||||
capped = min(remaining, _MAX_SAVABLE_AT_B4)
|
||||
capped = min(remaining, self._input.age4_savable_cap)
|
||||
excess = remaining - capped
|
||||
if excess > 0:
|
||||
cut_lagsta = min(excess, pool.lagsta_left)
|
||||
@@ -772,9 +788,15 @@ class _Ledger:
|
||||
parent: ParentFacts,
|
||||
child: ChildFacts | None,
|
||||
pool_key: tuple[UUID, UUID] | None,
|
||||
resolved_goal: str,
|
||||
early: bool,
|
||||
) -> tuple[str, str] | None:
|
||||
"""§3.3 tier-3 interior resolution — the single policy: maximise the
|
||||
value extracted from FP + vacation days combined. `salary_type` still
|
||||
branches (not a goal): a monthly-salaried parent loses nothing by
|
||||
spending a vacation day, so vacation goes first and FP (sjukpenning
|
||||
preferred) fills the rest; an hourly parent would lose pay to a
|
||||
vacation day, so FP goes first and vacation only backstops what FP
|
||||
can't cover.
|
||||
"""
|
||||
if child is None or pool_key is None:
|
||||
# No resolvable child (genuinely ambiguous family-wide wish, or a
|
||||
# stale child_id) -> no FP pool to draw from. Vacation or nothing.
|
||||
@@ -782,48 +804,14 @@ class _Ledger:
|
||||
return "vacation", REASON_VACATION_WRAP_WEEKEND
|
||||
return None
|
||||
|
||||
if resolved_goal == "max_money":
|
||||
if parent.salary_type == "monthly":
|
||||
if self._spend_vacation(parent.uuid, d):
|
||||
return "vacation", REASON_VACATION_WRAP_WEEKEND
|
||||
fp = self._spend_fp(pool_key, prefer="sjukpenning")
|
||||
if fp:
|
||||
return fp, (
|
||||
REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH
|
||||
)
|
||||
return None
|
||||
fp = self._spend_fp(pool_key, prefer="sjukpenning")
|
||||
if fp:
|
||||
return fp, (REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH)
|
||||
if self._spend_vacation(parent.uuid, d):
|
||||
return "vacation", REASON_VACATION_WRAP_WEEKEND
|
||||
return None
|
||||
|
||||
if resolved_goal == "max_time":
|
||||
if d < child.birth_date + relativedelta(months=12):
|
||||
# skip_fp_first_12_months: SGI is protected regardless, so
|
||||
# don't spend the scarce FP pool now — save it to stretch
|
||||
# later. Vacation only, or leave unfunded.
|
||||
if self._spend_vacation(parent.uuid, d):
|
||||
return "vacation", REASON_VACATION_WRAP_WEEKEND
|
||||
return None
|
||||
# stretch_time_at_home: draw the flat lagsta tier first to
|
||||
# preserve the higher-value sjukpenning tier for later.
|
||||
fp = self._spend_fp(pool_key, prefer="lagsta")
|
||||
if fp:
|
||||
return fp, (REASON_FP_LAGSTA_STRETCH if fp == "lagsta" else REASON_FP_INTERIOR)
|
||||
if self._spend_vacation(parent.uuid, d):
|
||||
return "vacation", REASON_VACATION_WRAP_WEEKEND
|
||||
return None
|
||||
|
||||
# 'balanced': semester early in the window, FP for the remainder.
|
||||
if early:
|
||||
if parent.salary_type == "monthly":
|
||||
if self._spend_vacation(parent.uuid, d):
|
||||
return "vacation", REASON_VACATION_WRAP_WEEKEND
|
||||
fp = self._spend_fp(pool_key, prefer="sjukpenning")
|
||||
if fp:
|
||||
return fp, (REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH)
|
||||
return None
|
||||
|
||||
fp = self._spend_fp(pool_key, prefer="sjukpenning")
|
||||
if fp:
|
||||
return fp, (REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH)
|
||||
@@ -838,7 +826,6 @@ class _Ledger:
|
||||
wish: LeaveWish,
|
||||
dates: list[date],
|
||||
red_cluster: set[date],
|
||||
resolved_goal: str,
|
||||
parent: ParentFacts,
|
||||
free_reason_override: str | None,
|
||||
) -> tuple[list[ProposedDay], dict[str, int]]:
|
||||
@@ -847,9 +834,8 @@ class _Ledger:
|
||||
child = self._resolve_wish_child(wish)
|
||||
child_id = child.id if child is not None else None
|
||||
pool_key = (parent.uuid, child_id) if child_id is not None else None
|
||||
half_index = (len(dates) + 1) // 2
|
||||
|
||||
for i, d in enumerate(dates):
|
||||
for d in dates:
|
||||
existing_type = self._existing_lookup.get((parent.uuid, child_id, d))
|
||||
if existing_type is not None:
|
||||
cost[existing_type] = cost.get(existing_type, 0) + 1
|
||||
@@ -877,9 +863,7 @@ class _Ledger:
|
||||
resolved = ("vacation", REASON_VACATION_WRAP_RED_DAY)
|
||||
|
||||
if resolved is None:
|
||||
resolved = self._assign_tier3(
|
||||
wish, d, parent, child, pool_key, resolved_goal, early=(i < half_index)
|
||||
)
|
||||
resolved = self._assign_tier3(wish, d, parent, child, pool_key)
|
||||
|
||||
if resolved is None:
|
||||
continue # unfunded — reflected in the window's shortfall_days
|
||||
@@ -896,7 +880,6 @@ class _Ledger:
|
||||
w: _WishWindow,
|
||||
dates: list[date],
|
||||
red_cluster: set[date],
|
||||
resolved_goal: str,
|
||||
responsible: _Responsible,
|
||||
) -> tuple[list[ProposedDay], dict[str, int]]:
|
||||
if not dates:
|
||||
@@ -912,10 +895,10 @@ class _Ledger:
|
||||
self._maybe_clamp_age4((parent_b.uuid, resolved_child.id), w.start)
|
||||
|
||||
days_a, cost_a = self._fund_solo(
|
||||
wish, first_half, red_cluster, resolved_goal, parent_a, REASON_SWAP_FIRST_HALF
|
||||
wish, first_half, red_cluster, parent_a, REASON_SWAP_FIRST_HALF
|
||||
)
|
||||
days_b, cost_b = self._fund_solo(
|
||||
wish, second_half, red_cluster, resolved_goal, parent_b, REASON_SWAP_SECOND_HALF
|
||||
wish, second_half, red_cluster, parent_b, REASON_SWAP_SECOND_HALF
|
||||
)
|
||||
merged_cost = {k: cost_a[k] + cost_b[k] for k in cost_a}
|
||||
return days_a + days_b, merged_cost
|
||||
@@ -926,14 +909,13 @@ class _Ledger:
|
||||
w: _WishWindow,
|
||||
dates: list[date],
|
||||
red_cluster: set[date],
|
||||
resolved_goal: str,
|
||||
responsible: _Responsible,
|
||||
) -> tuple[list[ProposedDay], dict[str, int]]:
|
||||
parent_a, parent_b = responsible.parents[0], responsible.parents[1]
|
||||
child = self._resolve_wish_child(wish)
|
||||
|
||||
if child is None:
|
||||
return self._fund_swap(wish, w, dates, red_cluster, resolved_goal, responsible)
|
||||
return self._fund_swap(wish, w, dates, red_cluster, responsible)
|
||||
|
||||
self._maybe_clamp_age4((parent_a.uuid, child.id), w.start)
|
||||
self._maybe_clamp_age4((parent_b.uuid, child.id), w.start)
|
||||
@@ -998,7 +980,7 @@ class _Ledger:
|
||||
|
||||
if leftover_for_swap:
|
||||
swap_days, swap_cost = self._fund_swap(
|
||||
wish, w, sorted(leftover_for_swap), red_cluster, resolved_goal, responsible
|
||||
wish, w, sorted(leftover_for_swap), red_cluster, responsible
|
||||
)
|
||||
days.extend(swap_days)
|
||||
for k in cost:
|
||||
@@ -1010,7 +992,6 @@ class _Ledger:
|
||||
|
||||
def fund_window(self, w: _WishWindow) -> tuple[list[ProposedDay], WindowOutcome]:
|
||||
wish = w.wish
|
||||
resolved_goal = wish.goal_override or self._input.planning_goal
|
||||
responsible = self._resolve_parents(wish)
|
||||
|
||||
all_dates = _dates_in(w.start, w.end)
|
||||
@@ -1019,21 +1000,17 @@ class _Ledger:
|
||||
)
|
||||
|
||||
if responsible.mode == "dubbeldag":
|
||||
days, cost = self._fund_both(
|
||||
wish, w, all_dates, red_cluster, resolved_goal, responsible
|
||||
)
|
||||
days, cost = self._fund_both(wish, w, all_dates, red_cluster, responsible)
|
||||
strategy = "dubbeldag"
|
||||
elif responsible.mode == "swap":
|
||||
days, cost = self._fund_swap(
|
||||
wish, w, all_dates, red_cluster, resolved_goal, responsible
|
||||
)
|
||||
days, cost = self._fund_swap(wish, w, all_dates, red_cluster, responsible)
|
||||
strategy = "parent_swap"
|
||||
else:
|
||||
parent = responsible.parents[0]
|
||||
resolved_child = self._resolve_wish_child(wish)
|
||||
if resolved_child is not None:
|
||||
self._maybe_clamp_age4((parent.uuid, resolved_child.id), w.start)
|
||||
days, cost = self._fund_solo(wish, all_dates, red_cluster, resolved_goal, parent, None)
|
||||
days, cost = self._fund_solo(wish, all_dates, red_cluster, parent, None)
|
||||
strategy = "solo"
|
||||
|
||||
funded_home_days = cost["free"] + cost["vacation"] + cost["sjukpenning"] + cost["lagsta"]
|
||||
@@ -1329,7 +1306,7 @@ class _Ledger:
|
||||
already-claimed date — the 'unplaced' case (§10.4), for the caller
|
||||
to report via `build_unplaced_outcome`.
|
||||
"""
|
||||
length_days = wish.target_weeks * 7
|
||||
length_days = wish.target_span_days()
|
||||
bound_length = (bound_end - bound_start).days + 1
|
||||
|
||||
if bound_length < length_days:
|
||||
@@ -1382,7 +1359,7 @@ class _Ledger:
|
||||
(`WindowOutcome`) rather than inventing a new failure mode; the only
|
||||
new value is `strategy = 'unplaced'`.
|
||||
"""
|
||||
length_days = wish.target_weeks * 7
|
||||
length_days = wish.target_span_days()
|
||||
bound_window = _WishWindow(
|
||||
wish=wish, anchor_year=anchor_year, start=bound_start, end=bound_end
|
||||
)
|
||||
@@ -1466,6 +1443,7 @@ class _Ledger:
|
||||
days_used=0,
|
||||
planned_fp_before_b4=existing_before + new_before,
|
||||
total_days=total_initial,
|
||||
max_savable=self._input.age4_savable_cap,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -1507,7 +1485,7 @@ class _Ledger:
|
||||
child = self._children_by_id.get(child_id)
|
||||
if child is None:
|
||||
continue
|
||||
savable = min(projected_left, _MAX_SAVABLE_AT_B4)
|
||||
savable = min(projected_left, self._input.age4_savable_cap)
|
||||
projections.append(
|
||||
Age4Projection(
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
@@ -1516,6 +1494,7 @@ class _Ledger:
|
||||
projected_left_at_b4=projected_left,
|
||||
savable_after_b4=savable,
|
||||
expiring_at_b4=expiring,
|
||||
savable_cap_at_b4=self._input.age4_savable_cap,
|
||||
)
|
||||
)
|
||||
return projections
|
||||
@@ -2023,8 +2002,8 @@ class AllocationInputLoader:
|
||||
return AllocationInput(
|
||||
today=today,
|
||||
horizon_end=horizon_end,
|
||||
planning_goal=ws.planning_goal,
|
||||
planning_mode=ws.planning_mode,
|
||||
age4_savable_cap=resolve_age4_savable_cap(ws.planning_mode, len(parents)),
|
||||
children=children,
|
||||
parents=parents,
|
||||
pools=pools,
|
||||
|
||||
@@ -4,8 +4,12 @@ The headline calculation (§6.5 ARCHITECTURE.md):
|
||||
|
||||
projected_used_by_B4 = days_used + planned_days(date < B4)
|
||||
projected_left_at_B4 = total_days - projected_used_by_B4
|
||||
savable_after_B4 = min(projected_left_at_B4, 96) # age-4 ceiling
|
||||
expiring_at_B4 = max(projected_left_at_B4 - 96, 0) # LOST days
|
||||
savable_after_B4 = min(projected_left_at_B4, CAP) # age-4 ceiling
|
||||
expiring_at_B4 = max(projected_left_at_B4 - CAP, 0) # LOST days
|
||||
|
||||
Where `CAP` is 96 (per child) normally, or 48 (per parent) for a `couple`
|
||||
workspace with two parents — see `app.utils.tip_predicates.
|
||||
resolve_age4_savable_cap`, the single function that decides which applies.
|
||||
|
||||
All rule constants come from config.LeaveRules — no magic numbers.
|
||||
dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic.
|
||||
@@ -24,8 +28,9 @@ from app.repositories.child_repository import ChildRepository
|
||||
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
||||
from app.repositories.personal_data_repository import PersonalDataRepository
|
||||
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
||||
from app.repositories.workspace_repository import WorkspaceRepository
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.tip_predicates import days_left_at_age_4_projection
|
||||
from app.utils.tip_predicates import days_left_at_age_4_projection, resolve_age4_savable_cap
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,8 +45,13 @@ class Age4Projection:
|
||||
days_used: int # from latest FK snapshot
|
||||
planned_days_before_b4: int # from calendar
|
||||
projected_left_at_b4: int
|
||||
savable_after_b4: int # min(projected_left, 96)
|
||||
expiring_at_b4: int # max(projected_left - 96, 0) — LOST if not used
|
||||
savable_after_b4: int # min(projected_left, savable_cap_at_b4)
|
||||
expiring_at_b4: int # max(projected_left - savable_cap_at_b4, 0) — LOST if not used
|
||||
# The cap actually applied — 96 (per child) or 48 (per parent, couple
|
||||
# workspace with 2 parents). See `resolve_age4_savable_cap`. Surfaced so
|
||||
# the frontend can render "X kvar / Y sparbara" without re-deriving the
|
||||
# rule itself.
|
||||
savable_cap_at_b4: int
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -61,11 +71,25 @@ class AnalyticsService:
|
||||
planned_leave_repo: PlannedLeaveRepository,
|
||||
child_repo: ChildRepository,
|
||||
personal_data_repo: PersonalDataRepository,
|
||||
workspace_repo: WorkspaceRepository,
|
||||
) -> None:
|
||||
self._leave = parental_leave_repo
|
||||
self._planned = planned_leave_repo
|
||||
self._children = child_repo
|
||||
self._parents = personal_data_repo
|
||||
self._workspace_repo = workspace_repo
|
||||
|
||||
async def _resolve_savable_cap(self, workspace_id: UUID) -> int:
|
||||
"""The single call site AnalyticsService uses for the age-4 cap —
|
||||
|
||||
wraps `resolve_age4_savable_cap` with the DB reads it needs
|
||||
(workspace planning_mode + member count) so callers never resolve
|
||||
the cap any other way.
|
||||
"""
|
||||
workspace = await self._workspace_repo.get_by_id(workspace_id)
|
||||
planning_mode = workspace.planning_mode if workspace is not None else "solo"
|
||||
members = await self._parents.list_by_workspace(workspace_id)
|
||||
return resolve_age4_savable_cap(planning_mode, len(members))
|
||||
|
||||
async def get_days_left_at_age_4(
|
||||
self, personal_data_uuid: UUID, child_id: UUID
|
||||
@@ -108,6 +132,8 @@ class AnalyticsService:
|
||||
)
|
||||
planned_fp_before_b4 = sjukpenning_before_b4 + lagsta_before_b4
|
||||
|
||||
savable_cap = await self._resolve_savable_cap(child.workspace_id)
|
||||
|
||||
# Shared with the plan-proposal side (`allocation_service._Ledger.
|
||||
# build_age4_projections`) so the two surfaces can never re-derive
|
||||
# this arithmetic differently — see `days_left_at_age_4_projection`.
|
||||
@@ -115,8 +141,9 @@ class AnalyticsService:
|
||||
days_used=days_used,
|
||||
planned_fp_before_b4=planned_fp_before_b4,
|
||||
total_days=total_days,
|
||||
max_savable=savable_cap,
|
||||
)
|
||||
savable = min(projected_left, rules.max_savable_days_at_age_4)
|
||||
savable = min(projected_left, savable_cap)
|
||||
|
||||
return Age4Projection(
|
||||
personal_data_uuid=personal_data_uuid,
|
||||
@@ -129,6 +156,7 @@ class AnalyticsService:
|
||||
projected_left_at_b4=projected_left,
|
||||
savable_after_b4=savable,
|
||||
expiring_at_b4=expiring,
|
||||
savable_cap_at_b4=savable_cap,
|
||||
)
|
||||
|
||||
async def get_overview(self, workspace_id: UUID, child_id: UUID) -> WorkspaceOverview:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Business logic for leave wishes (family recurring home-together intent).
|
||||
|
||||
See PLANNING_ENGINE_SPEC.md §2.1. This service owns the validation the
|
||||
allocator (Phase B) depends on: valid recurrence/parent_mode/goal_override,
|
||||
sane month/day ranges, and the parent_mode <-> parent_personal_data_uuid
|
||||
pairing rule.
|
||||
allocator (Phase B) depends on: valid recurrence/parent_mode, sane month/day
|
||||
ranges, and the parent_mode <-> parent_personal_data_uuid pairing rule.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
@@ -15,7 +14,6 @@ from app.utils.errors import NotFoundError, ValidationError
|
||||
|
||||
_VALID_RECURRENCE = {"yearly", "once"}
|
||||
_VALID_PARENT_MODE = {"either", "both", "specific"}
|
||||
_VALID_GOAL_OVERRIDE = {"max_time", "max_money", "balanced"}
|
||||
_VALID_WINDOW_MODE = {"fixed", "flexible"}
|
||||
|
||||
# A leap year, so day=29 for month=2 (Feb 29) is accepted when validating ranges.
|
||||
@@ -38,12 +36,12 @@ class LeaveWishService:
|
||||
first_year: int,
|
||||
child_id: UUID | None = None,
|
||||
name_message_key: str | None = None,
|
||||
target_days: int | None = None,
|
||||
recurrence: str = "once",
|
||||
last_year: int | None = None,
|
||||
priority: int = 1,
|
||||
parent_mode: str = "either",
|
||||
parent_personal_data_uuid: UUID | None = None,
|
||||
goal_override: str | None = None,
|
||||
window_mode: str = "fixed",
|
||||
) -> LeaveWish:
|
||||
_validate(
|
||||
@@ -52,12 +50,12 @@ class LeaveWishService:
|
||||
window_end_month=window_end_month,
|
||||
window_end_day=window_end_day,
|
||||
target_weeks=target_weeks,
|
||||
target_days=target_days,
|
||||
recurrence=recurrence,
|
||||
first_year=first_year,
|
||||
last_year=last_year,
|
||||
parent_mode=parent_mode,
|
||||
parent_personal_data_uuid=parent_personal_data_uuid,
|
||||
goal_override=goal_override,
|
||||
window_mode=window_mode,
|
||||
)
|
||||
now = datetime.now()
|
||||
@@ -72,13 +70,13 @@ class LeaveWishService:
|
||||
window_end_month=window_end_month,
|
||||
window_end_day=window_end_day,
|
||||
target_weeks=target_weeks,
|
||||
target_days=target_days,
|
||||
recurrence=recurrence,
|
||||
first_year=first_year,
|
||||
last_year=last_year,
|
||||
priority=priority,
|
||||
parent_mode=parent_mode,
|
||||
parent_personal_data_uuid=parent_personal_data_uuid,
|
||||
goal_override=goal_override,
|
||||
window_mode=window_mode,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -105,19 +103,19 @@ class LeaveWishService:
|
||||
window_end_month: int | None = None,
|
||||
window_end_day: int | None = None,
|
||||
target_weeks: int | None = None,
|
||||
target_days: int | None = None,
|
||||
recurrence: str | None = None,
|
||||
first_year: int | None = None,
|
||||
last_year: int | None = None,
|
||||
priority: int | None = None,
|
||||
parent_mode: str | None = None,
|
||||
parent_personal_data_uuid: UUID | None = None,
|
||||
goal_override: str | None = None,
|
||||
window_mode: str | None = None,
|
||||
_clear_child_id: bool = False,
|
||||
_clear_last_year: bool = False,
|
||||
_clear_parent_personal_data_uuid: bool = False,
|
||||
_clear_goal_override: bool = False,
|
||||
_clear_name_message_key: bool = False,
|
||||
_clear_target_days: bool = False,
|
||||
) -> LeaveWish:
|
||||
"""Partial update — only non-None fields are applied.
|
||||
|
||||
@@ -130,6 +128,9 @@ class LeaveWishService:
|
||||
regardless of what was passed for it, since a caller (frontend bug,
|
||||
future API client, ...) forgetting to clear it would silently make a
|
||||
user's edited name revert to a stale preset label.
|
||||
|
||||
`_clear_target_days` resets a day-granular wish back to being sized
|
||||
by `target_weeks` alone.
|
||||
"""
|
||||
entity = await self.get(leave_wish_id)
|
||||
original_name = entity.name
|
||||
@@ -156,6 +157,10 @@ class LeaveWishService:
|
||||
entity.window_end_day = window_end_day
|
||||
if target_weeks is not None:
|
||||
entity.target_weeks = target_weeks
|
||||
if _clear_target_days:
|
||||
entity.target_days = None
|
||||
elif target_days is not None:
|
||||
entity.target_days = target_days
|
||||
if recurrence is not None:
|
||||
entity.recurrence = recurrence
|
||||
if first_year is not None:
|
||||
@@ -172,10 +177,6 @@ class LeaveWishService:
|
||||
entity.parent_personal_data_uuid = None
|
||||
elif parent_personal_data_uuid is not None:
|
||||
entity.parent_personal_data_uuid = parent_personal_data_uuid
|
||||
if _clear_goal_override:
|
||||
entity.goal_override = None
|
||||
elif goal_override is not None:
|
||||
entity.goal_override = goal_override
|
||||
if window_mode is not None:
|
||||
entity.window_mode = window_mode
|
||||
|
||||
@@ -185,12 +186,12 @@ class LeaveWishService:
|
||||
window_end_month=entity.window_end_month,
|
||||
window_end_day=entity.window_end_day,
|
||||
target_weeks=entity.target_weeks,
|
||||
target_days=entity.target_days,
|
||||
recurrence=entity.recurrence,
|
||||
first_year=entity.first_year,
|
||||
last_year=entity.last_year,
|
||||
parent_mode=entity.parent_mode,
|
||||
parent_personal_data_uuid=entity.parent_personal_data_uuid,
|
||||
goal_override=entity.goal_override,
|
||||
window_mode=entity.window_mode,
|
||||
)
|
||||
return await self._repo.update(leave_wish_id, entity)
|
||||
@@ -207,20 +208,18 @@ def _validate(
|
||||
window_end_month: int,
|
||||
window_end_day: int,
|
||||
target_weeks: int,
|
||||
target_days: int | None,
|
||||
recurrence: str,
|
||||
first_year: int,
|
||||
last_year: int | None,
|
||||
parent_mode: str,
|
||||
parent_personal_data_uuid: UUID | None,
|
||||
goal_override: str | None,
|
||||
window_mode: str,
|
||||
) -> None:
|
||||
if recurrence not in _VALID_RECURRENCE:
|
||||
raise ValidationError(f"recurrence must be one of {_VALID_RECURRENCE}")
|
||||
if parent_mode not in _VALID_PARENT_MODE:
|
||||
raise ValidationError(f"parent_mode must be one of {_VALID_PARENT_MODE}")
|
||||
if goal_override is not None and goal_override not in _VALID_GOAL_OVERRIDE:
|
||||
raise ValidationError(f"goal_override must be one of {_VALID_GOAL_OVERRIDE} or None")
|
||||
if window_mode not in _VALID_WINDOW_MODE:
|
||||
raise ValidationError(f"window_mode must be one of {_VALID_WINDOW_MODE}")
|
||||
|
||||
@@ -229,6 +228,8 @@ def _validate(
|
||||
|
||||
if target_weeks <= 0:
|
||||
raise ValidationError("target_weeks must be > 0")
|
||||
if target_days is not None and target_days < 1:
|
||||
raise ValidationError("target_days must be >= 1")
|
||||
|
||||
if last_year is not None and last_year < first_year:
|
||||
raise ValidationError("last_year must be >= first_year")
|
||||
@@ -244,18 +245,20 @@ def _validate(
|
||||
|
||||
if window_mode == "flexible":
|
||||
# PLANNING_ENGINE_SPEC.md §10.1 — the bounding range's raw length
|
||||
# (before any horizon clipping) must be able to fit target_weeks;
|
||||
# "find a 6-week window inside this 2-week range" is a user-input
|
||||
# error, not a legitimate future shortfall.
|
||||
# (before any horizon clipping) must be able to fit the wanted
|
||||
# duration; "find a 6-week window inside this 2-week range" is a
|
||||
# user-input error, not a legitimate future shortfall. Mirrors
|
||||
# `LeaveWish.target_span_days()`: `target_days` wins when set,
|
||||
# otherwise `target_weeks * 7` (no entity exists yet in `create()`).
|
||||
bound_days = _bound_length_days(
|
||||
window_start_month, window_start_day, window_end_month, window_end_day, first_year
|
||||
)
|
||||
needed_days = target_weeks * 7
|
||||
needed_days = target_days if target_days is not None else target_weeks * 7
|
||||
if bound_days < needed_days:
|
||||
raise ValidationError(
|
||||
"flexible wish bounding range "
|
||||
f"({bound_days} days) is shorter than target_weeks "
|
||||
f"({target_weeks} weeks = {needed_days} days)"
|
||||
f"({bound_days} days) is shorter than the wanted duration "
|
||||
f"({needed_days} days)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -32,8 +32,12 @@ class ProposedDayInput:
|
||||
Deliberately a plain domain dataclass (not a Pydantic model) so the
|
||||
service stays independent of the wire schema in `app.models.plan` — the
|
||||
router is responsible for that translation. Shape mirrors
|
||||
`allocation_service.ProposedDay` minus the explain-only `wish_id`/`reason`
|
||||
fields, which carry no persistence meaning.
|
||||
`allocation_service.ProposedDay` minus the explain-only `reason` field,
|
||||
which carries no persistence meaning. `wish_id` DOES carry persistence
|
||||
meaning (unlike `reason`): it links the persisted `planned_leave_day` row
|
||||
back to the `LeaveWish` that produced it (nullable — ad-hoc/legacy days
|
||||
have none), which is what lets the UI later treat a multi-day band as one
|
||||
editable period instead of one day at a time.
|
||||
"""
|
||||
|
||||
date: date
|
||||
@@ -41,6 +45,9 @@ class ProposedDayInput:
|
||||
child_id: UUID
|
||||
day_type: str
|
||||
is_dubbeldag: bool = False
|
||||
wish_id: UUID | None = None
|
||||
# True once the user hand-edits this day; see `PlannedLeaveDay.is_pinned`.
|
||||
is_pinned: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -99,6 +106,8 @@ class PlanningService:
|
||||
date: date,
|
||||
day_type: str,
|
||||
is_dubbeldag: bool = False,
|
||||
wish_id: UUID | None = None,
|
||||
is_pinned: bool = False,
|
||||
) -> PlannedLeaveDay:
|
||||
"""Add a planned leave day.
|
||||
|
||||
@@ -131,6 +140,8 @@ class PlanningService:
|
||||
date=date,
|
||||
day_type=day_type,
|
||||
is_dubbeldag=is_dubbeldag,
|
||||
wish_id=wish_id,
|
||||
is_pinned=is_pinned,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -178,6 +189,8 @@ class PlanningService:
|
||||
planned_leave_day_id: UUID,
|
||||
day_type: str | None = None,
|
||||
is_dubbeldag: bool | None = None,
|
||||
wish_id: UUID | None = None,
|
||||
is_pinned: bool | None = None,
|
||||
) -> PlannedLeaveDay:
|
||||
"""Update a planned day (partial update)."""
|
||||
entity = await self._repo.get_by_id(planned_leave_day_id)
|
||||
@@ -188,6 +201,10 @@ class PlanningService:
|
||||
entity.day_type = day_type
|
||||
if is_dubbeldag is not None:
|
||||
entity.is_dubbeldag = is_dubbeldag
|
||||
if wish_id is not None:
|
||||
entity.wish_id = wish_id
|
||||
if is_pinned is not None:
|
||||
entity.is_pinned = is_pinned
|
||||
|
||||
return await self._repo.update(planned_leave_day_id, entity)
|
||||
|
||||
@@ -217,10 +234,15 @@ class PlanningService:
|
||||
|
||||
Idempotent per (workspace, child, parent, date): re-applying an
|
||||
identical batch creates nothing and is reported as all
|
||||
`skipped_identical_count`. A date already planned *differently* is a
|
||||
collision — by default the whole batch is refused and nothing is
|
||||
written; pass `replace=True` to overwrite those specific collisions
|
||||
(every other collision-free day in the batch is written too).
|
||||
`skipped_identical_count`. Identity here is `day_type`/`is_dubbeldag`
|
||||
only — a day whose only difference from what's persisted is
|
||||
`wish_id`/`is_pinned` still counts as `skipped_identical_count`, but
|
||||
is quietly written as a metadata-only update rather than left stale;
|
||||
it is never treated as a collision. A date already planned
|
||||
*differently* (in `day_type`/`is_dubbeldag`) is a collision — by
|
||||
default the whole batch is refused and nothing is written; pass
|
||||
`replace=True` to overwrite those specific collisions (every other
|
||||
collision-free day in the batch is written too).
|
||||
|
||||
Validates the entire batch up front (day types, per-day dubbeldagar
|
||||
window, batch-wide dubbeldagar quota, child eligibility window,
|
||||
@@ -247,7 +269,13 @@ class PlanningService:
|
||||
for d in persistable:
|
||||
key = (d.child_id, d.parent_personal_data_uuid, d.date)
|
||||
prior = deduped.get(key)
|
||||
if prior is not None and prior != d:
|
||||
# Conflict is keyed on day_type/is_dubbeldag only — the same
|
||||
# (child, parent, date) appearing twice with different `wish_id`/
|
||||
# `is_pinned` is not a genuine conflict (attribution metadata,
|
||||
# not the persisted day's identity); the later entry simply wins.
|
||||
if prior is not None and (
|
||||
prior.day_type != d.day_type or prior.is_dubbeldag != d.is_dubbeldag
|
||||
):
|
||||
structural_errors.append(
|
||||
Rejection(
|
||||
message_key="planApply.rejection.conflictingDuplicate",
|
||||
@@ -285,6 +313,7 @@ class PlanningService:
|
||||
|
||||
to_create: list[ProposedDayInput] = []
|
||||
to_replace: list[tuple[PlannedLeaveDay, ProposedDayInput]] = []
|
||||
to_update_metadata: list[tuple[PlannedLeaveDay, ProposedDayInput]] = []
|
||||
collisions: list[DayCollision] = []
|
||||
skipped_identical_count = 0
|
||||
|
||||
@@ -296,6 +325,14 @@ class PlanningService:
|
||||
continue
|
||||
if existing.day_type == d.day_type and existing.is_dubbeldag == d.is_dubbeldag:
|
||||
skipped_identical_count += 1
|
||||
# Identical-vs-collision is (and stays) keyed on
|
||||
# day_type/is_dubbeldag only. A row whose only difference is
|
||||
# `wish_id`/`is_pinned` is never a collision requiring
|
||||
# `replace=True` — it's a plain metadata update, written
|
||||
# below without touching `skipped_identical_count` or
|
||||
# `collisions`.
|
||||
if existing.wish_id != d.wish_id or existing.is_pinned != d.is_pinned:
|
||||
to_update_metadata.append((existing, d))
|
||||
continue
|
||||
collisions.append(
|
||||
DayCollision(
|
||||
@@ -322,7 +359,7 @@ class PlanningService:
|
||||
)
|
||||
|
||||
pending = to_create + [new for _, new in to_replace]
|
||||
if not pending:
|
||||
if not pending and not to_update_metadata:
|
||||
# Nothing left to write — either an empty batch or a fully
|
||||
# idempotent re-apply.
|
||||
return PlanApplyResult(
|
||||
@@ -355,11 +392,18 @@ class PlanningService:
|
||||
date=d.date,
|
||||
day_type=d.day_type,
|
||||
is_dubbeldag=d.is_dubbeldag,
|
||||
wish_id=d.wish_id,
|
||||
is_pinned=d.is_pinned,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
created.append(await self._repo.create(entity))
|
||||
|
||||
for existing, d in to_update_metadata:
|
||||
existing.wish_id = d.wish_id
|
||||
existing.is_pinned = d.is_pinned
|
||||
await self._repo.update(existing.id, existing)
|
||||
|
||||
return PlanApplyResult(
|
||||
accepted=True,
|
||||
created=created,
|
||||
|
||||
@@ -44,9 +44,9 @@ from app.utils.tip_predicates import (
|
||||
days_left_at_age_4_projection,
|
||||
has_vacation_near_expiry,
|
||||
is_child_over_age,
|
||||
is_child_under_age,
|
||||
preschool_buffer_needed,
|
||||
red_day_clusters_in_range,
|
||||
resolve_age4_savable_cap,
|
||||
sgi_at_risk,
|
||||
weekly_intensity,
|
||||
)
|
||||
@@ -63,7 +63,6 @@ class Tip:
|
||||
@dataclass
|
||||
class TipsContext:
|
||||
workspace_id: UUID
|
||||
workspace_planning_goal: str # 'max_time' | 'max_money' | 'balanced'
|
||||
children: list[Child]
|
||||
parents: list[PersonalData]
|
||||
leave_by_parent: dict[UUID, ParentalLeaveFetch | None] # personal_data_uuid → latest
|
||||
@@ -71,6 +70,10 @@ class TipsContext:
|
||||
vacation_by_parent: dict[UUID, list[VacationDays]]
|
||||
leave_wishes: list[LeaveWish]
|
||||
today: date # injected for deterministic testing
|
||||
# 'solo' | 'couple' — see `app.utils.tip_predicates.resolve_age4_savable_cap`.
|
||||
# Defaulted for backward compatibility with call sites/tests that don't
|
||||
# care about the age-4 cap split.
|
||||
workspace_planning_mode: str = "solo"
|
||||
|
||||
|
||||
# Type alias for a rule function.
|
||||
@@ -131,6 +134,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
|
||||
return None
|
||||
child = ctx.children[0]
|
||||
fourth_bday = child.birth_date + _relativedelta(years=4)
|
||||
savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents))
|
||||
for parent in ctx.parents:
|
||||
leave = ctx.leave_by_parent.get(parent.uuid)
|
||||
if leave is None:
|
||||
@@ -143,7 +147,7 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
|
||||
and d.date < fourth_bday
|
||||
)
|
||||
_, expiring_at_b4 = days_left_at_age_4_projection(
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days, savable_cap
|
||||
)
|
||||
if expiring_at_b4 > 0:
|
||||
return Tip(
|
||||
@@ -216,6 +220,7 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
|
||||
fourth_bday = child.birth_date + _relativedelta(years=4)
|
||||
today = ctx.today
|
||||
window_end = today + timedelta(weeks=4)
|
||||
savable_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents))
|
||||
|
||||
for parent in ctx.parents:
|
||||
parent_window_days = [
|
||||
@@ -237,7 +242,7 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
|
||||
and d.date < fourth_bday
|
||||
)
|
||||
projected_left, _ = days_left_at_age_4_projection(
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days
|
||||
leave.days_used, planned_fp_before_b4, leave.total_days, savable_cap
|
||||
)
|
||||
if projected_left < 60:
|
||||
return Tip(
|
||||
@@ -253,29 +258,6 @@ async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _rule_skip_fp_first_12_months(ctx: TipsContext) -> Tip | None:
|
||||
if ctx.workspace_planning_goal != "max_time":
|
||||
return None
|
||||
for child in ctx.children:
|
||||
if is_child_under_age(child.birth_date, months=12):
|
||||
for parent in ctx.parents:
|
||||
fp_planned = [
|
||||
d
|
||||
for d in ctx.planned_days
|
||||
if d.parent_personal_data_uuid == parent.uuid
|
||||
and d.day_type in ("sjukpenning", "lagsta")
|
||||
and d.date < child.birth_date + _relativedelta(months=12)
|
||||
]
|
||||
if fp_planned:
|
||||
return Tip(
|
||||
id="skip_fp_first_12_months",
|
||||
severity="info",
|
||||
message_key="tips.skip_fp_first_12_months",
|
||||
data={"parent_name": parent.name},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _rule_sgi_minimum_after_age_1(ctx: TipsContext) -> Tip | None:
|
||||
today = ctx.today
|
||||
for child in ctx.children:
|
||||
@@ -297,8 +279,6 @@ async def _rule_sgi_minimum_after_age_1(ctx: TipsContext) -> Tip | None:
|
||||
|
||||
|
||||
async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None:
|
||||
if ctx.workspace_planning_goal != "max_money":
|
||||
return None
|
||||
today = ctx.today
|
||||
look_ahead = today + timedelta(weeks=8)
|
||||
for parent in ctx.parents:
|
||||
@@ -329,8 +309,6 @@ async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None:
|
||||
|
||||
|
||||
async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None:
|
||||
if ctx.workspace_planning_goal != "max_money":
|
||||
return None
|
||||
today = ctx.today
|
||||
look_ahead = today + timedelta(weeks=12)
|
||||
clusters = red_day_clusters_in_range(today, look_ahead)
|
||||
@@ -455,7 +433,6 @@ RULE_REGISTRY: list[RuleFn] = [
|
||||
_rule_use_dubbeldagar_strategically,
|
||||
_rule_lagsta_niva_coverage,
|
||||
_rule_stretch_time_at_home,
|
||||
_rule_skip_fp_first_12_months,
|
||||
_rule_sgi_minimum_after_age_1,
|
||||
_rule_wrap_weekend_with_vacation,
|
||||
_rule_wrap_red_days_with_vacation,
|
||||
@@ -513,11 +490,10 @@ class TipsService:
|
||||
)
|
||||
|
||||
ws = await self._workspace_repo.get_by_id(workspace_id)
|
||||
planning_goal = ws.planning_goal if ws else "balanced"
|
||||
planning_mode = ws.planning_mode if ws else "solo"
|
||||
|
||||
return TipsContext(
|
||||
workspace_id=workspace_id,
|
||||
workspace_planning_goal=planning_goal,
|
||||
children=children,
|
||||
parents=parents,
|
||||
leave_by_parent=leave_by_parent,
|
||||
@@ -525,4 +501,5 @@ class TipsService:
|
||||
vacation_by_parent=vacation_by_parent,
|
||||
leave_wishes=leave_wishes,
|
||||
today=today,
|
||||
workspace_planning_mode=planning_mode,
|
||||
)
|
||||
|
||||
@@ -75,17 +75,11 @@ class WorkspaceService:
|
||||
self,
|
||||
workspace_id: UUID,
|
||||
name: str | None = None,
|
||||
planning_goal: str | None = None,
|
||||
planning_mode: str | None = None,
|
||||
) -> Workspace:
|
||||
ws = await self.get_workspace(workspace_id)
|
||||
if name is not None:
|
||||
ws.name = name
|
||||
if planning_goal is not None:
|
||||
valid = {"max_time", "max_money", "balanced"}
|
||||
if planning_goal not in valid:
|
||||
raise ValidationError(f"planning_goal must be one of {valid}")
|
||||
ws.planning_goal = planning_goal
|
||||
if planning_mode is not None:
|
||||
valid_modes = {"solo", "couple"}
|
||||
if planning_mode not in valid_modes:
|
||||
|
||||
@@ -30,19 +30,42 @@ def weekly_intensity(planned_days: list[PlannedLeaveDay], period_weeks: float) -
|
||||
return len(fp_days) / period_weeks
|
||||
|
||||
|
||||
def resolve_age4_savable_cap(planning_mode: str, parent_count: int) -> int:
|
||||
"""Single source of truth for which age-4 savable-days ceiling applies.
|
||||
|
||||
`rules.max_savable_days_at_age_4` (96) is the per-CHILD ceiling. When a
|
||||
child actually has two parents in a workspace explicitly flagged
|
||||
`planning_mode == 'couple'`, each parent's own balance is capped at
|
||||
`rules.max_savable_days_per_parent_at_age_4` (48) instead — 96 each would
|
||||
let the family save 192 combined, double the legal figure.
|
||||
|
||||
Requiring both conditions (couple mode AND >=2 parents) is a safety belt:
|
||||
a workspace flagged 'couple' that only has one member so far still gets
|
||||
the per-child (96) figure, since there is no second parent to divide it
|
||||
with. `AnalyticsService` and `allocation_service`'s `_Ledger` both call
|
||||
this so they can never disagree on which cap applies.
|
||||
"""
|
||||
rules = settings.rules
|
||||
if planning_mode == "couple" and parent_count >= 2:
|
||||
return rules.max_savable_days_per_parent_at_age_4
|
||||
return rules.max_savable_days_at_age_4
|
||||
|
||||
|
||||
def days_left_at_age_4_projection(
|
||||
days_used: int,
|
||||
planned_fp_before_b4: int,
|
||||
total_days: int,
|
||||
max_savable: int,
|
||||
) -> tuple[int, int]:
|
||||
"""Return (projected_left_at_b4, expiring_at_b4) without hitting the DB.
|
||||
|
||||
Delegates the full DB-backed version to AnalyticsService. Use this in
|
||||
tests or when the numbers are already loaded.
|
||||
`max_savable` is the resolved cap (see `resolve_age4_savable_cap`) — the
|
||||
caller decides which figure applies; this function only does the shared
|
||||
arithmetic so `AnalyticsService` and `allocation_service._Ledger` can
|
||||
never derive it differently.
|
||||
"""
|
||||
rules = settings.rules
|
||||
projected_left = total_days - (days_used + planned_fp_before_b4)
|
||||
expiring = max(projected_left - rules.max_savable_days_at_age_4, 0)
|
||||
expiring = max(projected_left - max_savable, 0)
|
||||
return projected_left, expiring
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user