"""任务状态机与"视图"口径。 两条原则: 1. **状态机是契约**:客户端据此置灰按钮,服务端必须再校验一次。 任何"直接改 status 列"的代码路径都是 bug。 2. **视图是查询口径,不是状态**:`today` / `week` 是筛选条件,不往 item 上落字段, 否则每天都要批量刷库,还会因为时区边界出错。 """ from __future__ import annotations from datetime import datetime, timedelta from zoneinfo import ZoneInfo from ..errors import AppError, ErrorCode # 允许的流转。没列出的就是禁止的(显式白名单,默认拒绝)。 TRANSITIONS: dict[str, frozenset[str]] = { # 待整理:进来先放着,决定做或不做 "inbox": frozenset({"todo", "canceled"}), # 待办:可以开工,也可以直接标记完成(写文档类的事不需要"进行中") "todo": frozenset({"inbox", "doing", "done", "canceled"}), # 进行中 "doing": frozenset({"todo", "done", "blocked", "canceled"}), # 被阻塞(M2 起:任务升级成工单会自动进这里) "blocked": frozenset({"doing", "canceled"}), # 已完成:只能重开 "done": frozenset({"todo"}), # 已取消:只能重开 "canceled": frozenset({"todo"}), } # 表示"这件事还没完"的状态 OPEN_STATUSES: tuple[str, ...] = ("inbox", "todo", "doing", "blocked") ACTIVE_STATUSES: tuple[str, ...] = ("todo", "doing", "blocked") ALL_STATUSES: tuple[str, ...] = tuple(TRANSITIONS) # 视图 → 状态集合。`today` / `week` 还要叠加截止时间窗口(见 due_window) VIEW_STATUSES: dict[str, tuple[str, ...]] = { "inbox": ("inbox",), "active": ACTIVE_STATUSES, "today": ACTIVE_STATUSES, "week": ACTIVE_STATUSES, "done": ("done",), "canceled": ("canceled",), "all": ALL_STATUSES, } DEFAULT_VIEW = "active" def can_transition(current: str, target: str) -> bool: return target in TRANSITIONS.get(current, frozenset()) def ensure_transition(current: str, target: str) -> None: """非法流转直接拒绝,并把"当前能去哪"一起返回,客户端不用自己猜。""" if current == target: return if not can_transition(current, target): raise AppError( ErrorCode.ITEM_STATUS_INVALID, f"状态不能从 {current} 直接变成 {target}", details={ "from": current, "to": target, "allowed": sorted(TRANSITIONS.get(current, ())), }, ) def apply_status(item: dict, target: str, *, now: datetime) -> None: """就地更新 status/completed_at。`completed_at` 只在 done 时有值。""" item["status"] = target item["completed_at"] = now if target == "done" else None def view_statuses(view: str | None) -> tuple[str, ...]: return VIEW_STATUSES.get(view or DEFAULT_VIEW, ACTIVE_STATUSES) def due_window( view: str | None, *, now: datetime, tz: str ) -> tuple[datetime | None, datetime | None]: """返回 (due_from, due_to) 半开区间 `[from, to)`。 `today` = 今天 00:00(含)到明天 00:00(不含),按**用户时区**算, 不是 UTC —— 中国用户 23:30 创建"今天到期"的任务,UTC 下已经是明天了。 """ if view not in ("today", "week"): return None, None zone = ZoneInfo(tz) local_now = now.astimezone(zone) start = local_now.replace(hour=0, minute=0, second=0, microsecond=0) if view == "today": end = start + timedelta(days=1) else: # 本周剩余:到今天所在自然周的周日结束 end = start + timedelta(days=7 - start.weekday()) return start, end