BE-CAL-005: Implement plan virtual-slot identification and materialization

- New service: app/services/plan_slot.py
  - Virtual slot ID: plan-{plan_id}-{YYYY-MM-DD} format with parse/make helpers
  - Plan-date matching: on_month/on_week/on_day hierarchy with week_of_month calc
  - Materialization: convert virtual slot to real TimeSlot row from plan template
  - Detach: clear plan_id after edit/cancel to break plan association
  - Bulk materialization: materialize_all_for_date for daily pre-compute
- New tests: tests/test_plan_slot.py (23 tests, all passing)
This commit is contained in:
zhi
2026-03-30 23:47:07 +00:00
parent eb57197020
commit a5b885e8b5
2 changed files with 613 additions and 0 deletions

329
app/services/plan_slot.py Normal file
View File

@@ -0,0 +1,329 @@
"""Plan virtual-slot identification and materialization.
BE-CAL-005: Implements the ``plan-{plan_id}-{date}`` virtual slot ID scheme,
matching logic to determine which plans fire on a given date, and
materialization (converting a virtual slot into a real TimeSlot row).
Design references:
- NEXT_WAVE_DEV_DIRECTION.md §2 (Slot ID策略)
- NEXT_WAVE_DEV_DIRECTION.md §3 (存储与缓存策略)
Key rules:
1. A virtual slot is identified by ``plan-{plan_id}-{YYYY-MM-DD}``.
2. A plan matches a date if all its period parameters (on_month, on_week,
on_day, at_time) align with that date.
3. A virtual slot is **not** generated for a date if a materialized
TimeSlot already exists for that (plan_id, date) pair.
4. Materialization creates a real TimeSlot row from the plan template and
returns it.
5. After edit/cancel of a materialized slot, ``plan_id`` is set to NULL so
the plan no longer "claims" that date — but the row persists.
"""
from __future__ import annotations
import calendar as _cal
import re
from datetime import date, datetime, time
from typing import Optional
from sqlalchemy.orm import Session
from app.models.calendar import (
DayOfWeek,
MonthOfYear,
SchedulePlan,
SlotStatus,
TimeSlot,
)
# ---------------------------------------------------------------------------
# Virtual-slot identifier helpers
# ---------------------------------------------------------------------------
_VIRTUAL_ID_RE = re.compile(r"^plan-(\d+)-(\d{4}-\d{2}-\d{2})$")
def make_virtual_slot_id(plan_id: int, slot_date: date) -> str:
"""Build the canonical virtual-slot identifier string."""
return f"plan-{plan_id}-{slot_date.isoformat()}"
def parse_virtual_slot_id(virtual_id: str) -> tuple[int, date] | None:
"""Parse ``plan-{plan_id}-{YYYY-MM-DD}`` → ``(plan_id, date)`` or *None*."""
m = _VIRTUAL_ID_RE.match(virtual_id)
if m is None:
return None
plan_id = int(m.group(1))
slot_date = date.fromisoformat(m.group(2))
return plan_id, slot_date
# ---------------------------------------------------------------------------
# Plan-date matching
# ---------------------------------------------------------------------------
# Mapping from DayOfWeek enum to Python weekday (Mon=0 … Sun=6)
_DOW_TO_WEEKDAY = {
DayOfWeek.MON: 0,
DayOfWeek.TUE: 1,
DayOfWeek.WED: 2,
DayOfWeek.THU: 3,
DayOfWeek.FRI: 4,
DayOfWeek.SAT: 5,
DayOfWeek.SUN: 6,
}
# Mapping from MonthOfYear enum to calendar month number
_MOY_TO_MONTH = {
MonthOfYear.JAN: 1,
MonthOfYear.FEB: 2,
MonthOfYear.MAR: 3,
MonthOfYear.APR: 4,
MonthOfYear.MAY: 5,
MonthOfYear.JUN: 6,
MonthOfYear.JUL: 7,
MonthOfYear.AUG: 8,
MonthOfYear.SEP: 9,
MonthOfYear.OCT: 10,
MonthOfYear.NOV: 11,
MonthOfYear.DEC: 12,
}
def _week_of_month(d: date) -> int:
"""Return the 1-based week-of-month for *d*.
Week 1 contains the first occurrence of the same weekday in that month.
For example, if the month starts on Wednesday:
- Wed 1st → week 1
- Wed 8th → week 2
- Thu 2nd → week 1 (first Thu of month)
"""
first_day = d.replace(day=1)
# How many days from the first occurrence of this weekday?
first_occurrence = 1 + (d.weekday() - first_day.weekday()) % 7
return (d.day - first_occurrence) // 7 + 1
def plan_matches_date(plan: SchedulePlan, target_date: date) -> bool:
"""Return *True* if *plan*'s recurrence rule fires on *target_date*.
Checks (most restrictive first):
1. on_month → target month must match
2. on_week → target week-of-month must match
3. on_day → target weekday must match
4. If none of the above are set → matches every day
"""
if not plan.is_active:
return False
# Month filter
if plan.on_month is not None:
if target_date.month != _MOY_TO_MONTH[plan.on_month]:
return False
# Week-of-month filter
if plan.on_week is not None:
if _week_of_month(target_date) != plan.on_week:
return False
# Day-of-week filter
if plan.on_day is not None:
if target_date.weekday() != _DOW_TO_WEEKDAY[plan.on_day]:
return False
return True
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
def get_matching_plans(
db: Session,
user_id: int,
target_date: date,
) -> list[SchedulePlan]:
"""Return all active plans for *user_id* that match *target_date*."""
plans = (
db.query(SchedulePlan)
.filter(
SchedulePlan.user_id == user_id,
SchedulePlan.is_active.is_(True),
)
.all()
)
return [p for p in plans if plan_matches_date(p, target_date)]
def get_materialized_plan_dates(
db: Session,
plan_id: int,
target_date: date,
) -> bool:
"""Return *True* if a materialized slot already exists for (plan_id, date)."""
return (
db.query(TimeSlot.id)
.filter(
TimeSlot.plan_id == plan_id,
TimeSlot.date == target_date,
)
.first()
) is not None
def get_virtual_slots_for_date(
db: Session,
user_id: int,
target_date: date,
) -> list[dict]:
"""Return virtual-slot dicts for plans that match *target_date* but have
not yet been materialized.
Each dict mirrors the TimeSlot column structure plus a ``virtual_id``
field, making it easy to merge with real slots in the API layer.
"""
plans = get_matching_plans(db, user_id, target_date)
virtual_slots: list[dict] = []
for plan in plans:
if get_materialized_plan_dates(db, plan.id, target_date):
continue # already materialized — skip
virtual_slots.append({
"virtual_id": make_virtual_slot_id(plan.id, target_date),
"plan_id": plan.id,
"user_id": plan.user_id,
"date": target_date,
"slot_type": plan.slot_type,
"estimated_duration": plan.estimated_duration,
"scheduled_at": plan.at_time,
"started_at": None,
"attended": False,
"actual_duration": None,
"event_type": plan.event_type,
"event_data": plan.event_data,
"priority": 0,
"status": SlotStatus.NOT_STARTED,
})
return virtual_slots
# ---------------------------------------------------------------------------
# Materialization
# ---------------------------------------------------------------------------
def materialize_slot(
db: Session,
plan_id: int,
target_date: date,
) -> TimeSlot:
"""Materialize a virtual slot into a real TimeSlot row.
Copies template fields from the plan. The returned row is flushed
(has an ``id``) but the caller must ``commit()`` the transaction.
Raises ``ValueError`` if the plan does not exist, is inactive, does
not match the target date, or has already been materialized for that date.
"""
plan = db.query(SchedulePlan).filter(SchedulePlan.id == plan_id).first()
if plan is None:
raise ValueError(f"Plan {plan_id} not found")
if not plan.is_active:
raise ValueError(f"Plan {plan_id} is inactive")
if not plan_matches_date(plan, target_date):
raise ValueError(
f"Plan {plan_id} does not match date {target_date.isoformat()}"
)
if get_materialized_plan_dates(db, plan_id, target_date):
raise ValueError(
f"Plan {plan_id} already materialized for {target_date.isoformat()}"
)
slot = TimeSlot(
user_id=plan.user_id,
date=target_date,
slot_type=plan.slot_type,
estimated_duration=plan.estimated_duration,
scheduled_at=plan.at_time,
event_type=plan.event_type,
event_data=plan.event_data,
priority=0,
status=SlotStatus.NOT_STARTED,
plan_id=plan.id,
)
db.add(slot)
db.flush()
return slot
def materialize_from_virtual_id(
db: Session,
virtual_id: str,
) -> TimeSlot:
"""Parse a virtual-slot identifier and materialize it.
Convenience wrapper around :func:`materialize_slot`.
"""
parsed = parse_virtual_slot_id(virtual_id)
if parsed is None:
raise ValueError(f"Invalid virtual slot id: {virtual_id!r}")
plan_id, target_date = parsed
return materialize_slot(db, plan_id, target_date)
# ---------------------------------------------------------------------------
# Disconnect plan after edit/cancel
# ---------------------------------------------------------------------------
def detach_slot_from_plan(slot: TimeSlot) -> None:
"""Clear the ``plan_id`` on a materialized slot.
Called after edit or cancel to ensure the plan no longer "claims"
this date — the row persists with its own lifecycle.
"""
slot.plan_id = None
# ---------------------------------------------------------------------------
# Bulk materialization (daily pre-compute)
# ---------------------------------------------------------------------------
def materialize_all_for_date(
db: Session,
user_id: int,
target_date: date,
) -> list[TimeSlot]:
"""Materialize every matching plan for *user_id* on *target_date*.
Skips plans that are already materialized. Returns the list of
newly created TimeSlot rows (flushed, caller must commit).
"""
plans = get_matching_plans(db, user_id, target_date)
created: list[TimeSlot] = []
for plan in plans:
if get_materialized_plan_dates(db, plan.id, target_date):
continue
slot = TimeSlot(
user_id=plan.user_id,
date=target_date,
slot_type=plan.slot_type,
estimated_duration=plan.estimated_duration,
scheduled_at=plan.at_time,
event_type=plan.event_type,
event_data=plan.event_data,
priority=0,
status=SlotStatus.NOT_STARTED,
plan_id=plan.id,
)
db.add(slot)
created.append(slot)
if created:
db.flush()
return created