Files
DadaoTask/backend/app/domain/ticket.py
T
dadao 30caaaf1d9 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
2026-10-28 13:20:07 +08:00

139 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""工单状态机与权限判定(纯函数,不碰 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