"""Domain entity: DayAssertion — one user assertion about one calendar day. Restructure step 3 (intent vs. derived output — see `~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`). Replaces THREE previously-conflated concepts on `planned_leave_day`: - a **pinned day** (`PlannedLeaveDay.is_pinned=True`) = an assertion with a concrete `day_type`. - an **ad-hoc day** (`PlannedLeaveDay.wish_id is None`) = an assertion with no wish attached. - a **`PlannedDayExclusion`** row = an assertion with `day_type='none'` ("planner, never place a day here"). One row per (workspace, child, parent, date) — see the natural-key UNIQUE constraint in the migration/bootstrap DDL. `wish_id` is attachment only (for calendar band naming/grouping); it does NOT mean the planner owns the row — ownership is the whole point an assertion asserts against. Deliberately NO strength/priority field: an assertion is absolute by nature. Strength only matters *between wishes* (`LeaveWish.priority` already exists for that) — adding it here would reintroduce the exact ambiguity this table exists to remove. Step 3a (current): `day_assertion` is additive. `planned_leave_day.is_pinned` and `planned_day_exclusion` stay in place, unused by anything new, so the migration is reversible and step 3b can drop them once the frontend moves off the compatibility shim (see `PlanningService`/`PlannedDayExclusionService`). """ from collections.abc import Iterable from dataclasses import dataclass from datetime import date, datetime from uuid import UUID # `day_type='none'` is the assertion-shaped equivalent of a `PlannedDayExclusion` # row: "the planner may not place leave here." Not a real `planned_leave_day` # type (that CHECK constraint still only allows sjukpenning/lagsta/vacation). DAY_TYPE_NONE = "none" @dataclass class DayAssertion: id: UUID workspace_id: UUID child_id: UUID parent_personal_data_uuid: UUID date: date day_type: str # 'sjukpenning' | 'lagsta' | 'vacation' | 'none' is_dubbeldag: bool = False # Attachment only — see module docstring. Nullable: an ad-hoc assertion # (the pre-restructure "wish_id IS NULL" shape) has none. wish_id: UUID | None = None created_at: datetime | None = None updated_at: datetime | None = None def partition_assertion_keys( assertions: Iterable[DayAssertion], ) -> tuple[frozenset[tuple[UUID, UUID, date]], frozenset[tuple[UUID, UUID, date]]]: """Split a batch of assertions into `(pinned_keys, excluded_keys)`. Restructure step 3b. Both sets are keyed `(parent_personal_data_uuid, child_id, date)` — the shape every consumer wants: `AllocationInputLoader` (via `AllocationInput.pinned_days`/ `excluded_days`), `PlanningService.sync_plan`'s own pre-write snapshot, and `PlanningService`'s read-side `is_pinned` annotation on `PlannedLeaveDayResponse`. A `day_type != 'none'` assertion is "pinned" (immovable, concrete); `day_type == 'none'` is "excluded" (the planner may never place a day here). This is deliberately the ONE function every reader of "is this day locked" calls. Step 3a shipped two independent readers of the equivalent `is_pinned` signal (the allocator's loader, and `sync_plan`'s own separate `list()` snapshot) that disagreed — a day pinned only via `DayAssertion` was kept by the allocator but then deleted by `sync_plan` (see the restructure notes' "Bug found in 3a"). Routing every reader through this single, pure function is what makes that bug class structurally impossible rather than merely fixed once. """ pinned: set[tuple[UUID, UUID, date]] = set() excluded: set[tuple[UUID, UUID, date]] = set() for a in assertions: key = (a.parent_personal_data_uuid, a.child_id, a.date) if a.day_type == DAY_TYPE_NONE: excluded.add(key) else: pinned.add(key) return frozenset(pinned), frozenset(excluded)