feat(ticket): 工单协同后端——升级不搬数据、受理到验收、站内通知

- item.kind='ticket' + ticket_ext 1:1,迁移 0003 同时重写 item 状态约束为任务∪工单并集
- 工单状态机与动作权限:受理/开工/提交限办理人,验收限需求人(办理人不能自验)
- 升级时原任务转 blocked,验收通过后自动回流 todo,事件流两侧都留痕
- item_link(父子/相关)与 notification(只发当事人),三张表均开 RLS
- 抽出 serializers.py 复用任务序列化,修复工单响应缺 tags 导致前端白屏
- 评论与 PATCH 放开给工单;conftest 清库前终止残留事务,避免 TRUNCATE 死等锁
- tests/test_tickets.py 11 个用例,全量 57 passed
This commit is contained in:
dadao
2026-10-28 13:20:07 +08:00
parent 3d02fe845a
commit 30caaaf1d9
11 changed files with 1849 additions and 49 deletions
+138
View File
@@ -0,0 +1,138 @@
"""工单状态机与权限判定(纯函数,不碰 ORM 以外的世界)。
状态机:
open ──accept──▶ accepted ──start──▶ in_progress ──submit──▶ pending_verify
▲ │
└────────reject────────────┘
│approve
▼
done
`canceled` 可以从任何未终结状态进入;`done` 与 `canceled` 是终态。
两条刻意的规则:
1. **驳回不是打回 open,而是回 in_progress**。办理人已经在做了,让他重新"认领"一次纯属折腾;
驳回次数单独计数(`reject_count`),用来提示"这事返工过"。
2. **谁也不能替别人验收**,除了管理员/所有者。提需求的人验收是这套流程的核心信任点,
如果办理人能自己点"通过",工单就退化成"打个勾"。
"""
from __future__ import annotations
import uuid
from ..errors import AppError, ErrorCode
TICKET_TRANSITIONS: dict[str, tuple[str, ...]] = {
"open": ("accepted", "canceled"),
"accepted": ("in_progress", "canceled"),
"in_progress": ("pending_verify", "canceled"),
"pending_verify": ("done", "in_progress", "canceled"),
"done": (),
"canceled": (),
}
# 动作 → (允许的前置状态, 目标状态);cancel 特殊处理
ACTION_RULES: dict[str, tuple[tuple[str, ...], str]] = {
"accept": (("open",), "accepted"),
"start": (("accepted",), "in_progress"),
"submit": (("in_progress",), "pending_verify"),
"approve": (("pending_verify",), "done"),
"reject": (("pending_verify",), "in_progress"),
}
CANCELABLE = ("open", "accepted", "in_progress", "pending_verify")
NOW_FIELD: dict[str, str] = {
"accepted": "accepted_at",
"pending_verify": "submitted_at",
"done": "verified_at",
}
def ensure_transition(current: str, target: str) -> None:
allowed = TICKET_TRANSITIONS.get(current, ())
if target not in allowed:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"工单当前是「{current}」,不能直接变成「{target}」",
details={"from": current, "to": target, "allowed": list(allowed)},
)
def target_status(action: str, current: str) -> str:
"""动作 → 目标状态,顺带校验前置状态。"""
if action == "cancel":
if current not in CANCELABLE:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"「{current}」的工单不能取消",
details={"from": current, "allowed": list(CANCELABLE)},
)
return "canceled"
if action not in ACTION_RULES:
raise AppError(ErrorCode.BAD_REQUEST, f"未知动作:{action}")
allowed_from, target = ACTION_RULES[action]
if current not in allowed_from:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"工单当前是「{current}」,不能执行「{action}」",
# allowed 回答"现在还能去哪儿",allowed_from 回答"这个动作要什么前置状态":
# 客户端两种都要用得上,别让它自己拼。
details={
"from": current,
"action": action,
"allowed": list(TICKET_TRANSITIONS.get(current, ())),
"allowed_from": list(allowed_from),
},
)
return target
def ensure_can_act(
*,
action: str,
actor_id: uuid.UUID,
role: str,
assignee_id: uuid.UUID | None,
requester_id: uuid.UUID | None,
) -> None:
"""谁能做这个动作。
管理员/所有者可以代任何人操作(人休假、离职时的兜底),
普通成员只能动自己的位置上的事。
"""
is_admin = role in ("owner", "admin")
is_assignee = assignee_id is not None and actor_id == assignee_id
is_requester = requester_id is not None and actor_id == requester_id
if action in ("accept", "start", "submit"):
allowed = is_assignee or is_admin
hint = "只有被指派的人才能操作这条工单"
elif action in ("approve", "reject"):
allowed = is_requester or is_admin
hint = "只有提出这条工单的人(或管理员)才能验收"
elif action == "cancel":
allowed = is_requester or is_assignee or is_admin
hint = "只有提出人、办理人或管理员能取消"
else:
allowed = is_admin or is_requester
hint = "权限不足"
if not allowed:
raise AppError(ErrorCode.TICKET_FORBIDDEN, hint, details={"action": action})
def returned_task_status(ticket_status: str) -> str | None:
"""工单状态变化后,原任务该回到什么状态;None 表示不动。
闭环回流只发生在验收通过那一刻——这是"事情真的办完了"的唯一信号。
取消不回填:取消意味着不办了,原任务继续躺在 blocked 里等人处理才对。
"""
if ticket_status == "done":
return "todo"
return None