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:
+34
-46
@@ -28,7 +28,9 @@ from ..domain.task import (
|
||||
)
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||||
from ..models_ticket import TicketExt
|
||||
from ..schemas import Pagination, encode_cursor, ok
|
||||
from ..serializers import item_out, tag_out, tags_of
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
|
||||
tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"])
|
||||
@@ -76,36 +78,20 @@ class TagIn(BaseModel):
|
||||
color: str = Field(default="", max_length=16)
|
||||
|
||||
|
||||
# ── 序列化 ──────────────────────────────────────────────────────────────
|
||||
def _tag_out(tag: Tag) -> dict[str, Any]:
|
||||
return {"id": str(tag.id), "name": tag.name, "color": tag.color}
|
||||
|
||||
|
||||
def _item_out(item: Item, tags: list[Tag] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(item.id),
|
||||
"kind": item.kind,
|
||||
"title": item.title,
|
||||
"body_md": item.body_md,
|
||||
"status": item.status,
|
||||
"priority": item.priority,
|
||||
"creator_id": str(item.creator_id),
|
||||
"assignee_id": str(item.assignee_id) if item.assignee_id else None,
|
||||
"due_at": item.due_at,
|
||||
"completed_at": item.completed_at,
|
||||
"sort_order": item.sort_order,
|
||||
"deleted_at": item.deleted_at,
|
||||
"tags": [_tag_out(t) for t in (tags or [])],
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
"version": item.version,
|
||||
}
|
||||
# ── 序列化(与工单接口共用一份,两份拼装迟早会不一致)─────────────────────
|
||||
_tag_out = tag_out
|
||||
_item_out = item_out
|
||||
|
||||
|
||||
# ── 内部工具 ────────────────────────────────────────────────────────────
|
||||
async def _load_item(session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID) -> Item:
|
||||
async def _load_item(
|
||||
session: AsyncSession,
|
||||
ws_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
kinds: tuple[str, ...] = ("task",),
|
||||
) -> Item:
|
||||
item = await session.get(Item, item_id)
|
||||
if item is None or item.workspace_id != ws_id or item.kind != "task":
|
||||
if item is None or item.workspace_id != ws_id or item.kind not in kinds:
|
||||
# 跨租户 / 不存在 / 不是任务,对外一律"不存在",不泄漏别人有没有这条数据
|
||||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在")
|
||||
return item
|
||||
@@ -114,20 +100,7 @@ async def _load_item(session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID
|
||||
async def _tags_of(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
||||
) -> dict[uuid.UUID, list[Tag]]:
|
||||
if not item_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(ItemTag.item_id, Tag)
|
||||
.join(Tag, Tag.id == ItemTag.tag_id)
|
||||
.where(ItemTag.workspace_id == ws_id, ItemTag.item_id.in_(item_ids))
|
||||
.order_by(Tag.name)
|
||||
)
|
||||
).all()
|
||||
grouped: dict[uuid.UUID, list[Tag]] = {}
|
||||
for item_id, tag in rows:
|
||||
grouped.setdefault(item_id, []).append(tag)
|
||||
return grouped
|
||||
return await tags_of(session, ws_id, item_ids)
|
||||
|
||||
|
||||
def _normalize_tag_names(names: list[str]) -> list[str]:
|
||||
@@ -415,9 +388,9 @@ async def board(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{item_id}", summary="任务详情(含最近 50 条事件)")
|
||||
@router.get("/{item_id}", summary="事项详情(任务或工单,含最近 50 条事件)")
|
||||
async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
item = await _load_item(session, ctx.ws_id, item_id)
|
||||
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
activities = (
|
||||
(
|
||||
@@ -442,6 +415,20 @@ async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> d
|
||||
}
|
||||
for a in activities
|
||||
]
|
||||
if item.kind == "ticket":
|
||||
ext = await session.get(TicketExt, item.id)
|
||||
if ext is not None:
|
||||
data["ticket"] = {
|
||||
"requester_id": str(ext.requester_id),
|
||||
"assignee_id": str(ext.assignee_id),
|
||||
"parent_item_id": str(ext.parent_item_id) if ext.parent_item_id else None,
|
||||
"accepted_at": ext.accepted_at,
|
||||
"submitted_at": ext.submitted_at,
|
||||
"verified_at": ext.verified_at,
|
||||
"verified_by": str(ext.verified_by) if ext.verified_by else None,
|
||||
"reject_count": ext.reject_count,
|
||||
"resolution_md": ext.resolution_md,
|
||||
}
|
||||
return ok(data)
|
||||
|
||||
|
||||
@@ -556,11 +543,12 @@ async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep)
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{item_id}", summary="修改任务字段(乐观锁)")
|
||||
@router.patch("/{item_id}", summary="修改事项字段(乐观锁)")
|
||||
async def update_item(
|
||||
item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep
|
||||
) -> dict:
|
||||
item = await _load_item(session, ctx.ws_id, item_id)
|
||||
# 工单也能改标题/优先级/截止/标签;状态必须走 /ticket/* 的动作接口
|
||||
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||||
|
||||
values: dict[str, Any] = {}
|
||||
changes: dict[str, Any] = {}
|
||||
@@ -662,7 +650,7 @@ async def delete_item(
|
||||
async def add_comment(
|
||||
item_id: uuid.UUID, body: CommentIn, ctx: MemberDep, session: SessionDep
|
||||
) -> dict:
|
||||
item = await _load_item(session, ctx.ws_id, item_id)
|
||||
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||||
comment = Comment(
|
||||
workspace_id=ctx.ws_id,
|
||||
item_id=item.id,
|
||||
@@ -686,7 +674,7 @@ async def add_comment(
|
||||
|
||||
@router.get("/{item_id}/comments", summary="评论列表")
|
||||
async def list_comments(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
item = await _load_item(session, ctx.ws_id, item_id)
|
||||
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
"""工单域接口(M2):升级、指派、办理、验收、回流、通知、事项关系。
|
||||
|
||||
一段流程长什么样:
|
||||
|
||||
任务卡住了
|
||||
-> POST /items/{task_id}/promote {assignee_id} 任务变 blocked,生成一条工单(open)
|
||||
-> 被指派人 POST /items/{ticket_id}/ticket/accept accepted
|
||||
-> POST /items/{ticket_id}/ticket/start in_progress
|
||||
-> POST /items/{ticket_id}/ticket/submit pending_verify(提需求的人收到通知)
|
||||
-> POST /items/{ticket_id}/ticket/verify {approve} done,原任务自动 blocked -> todo
|
||||
|
||||
驳回走同一个入口:verify {approve: false, note: "..."} -> 回到 in_progress,reject_count + 1。
|
||||
每一步都写 activity;通知只是提醒,状态永远以 item 为准。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import Select, and_, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..audit import record as audit_record
|
||||
from ..deps import MemberDep, SessionDep, TenantCtx
|
||||
from ..domain.ticket import (
|
||||
CANCELABLE,
|
||||
NOW_FIELD,
|
||||
ensure_can_act,
|
||||
returned_task_status,
|
||||
target_status,
|
||||
)
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member
|
||||
from ..models_task import Activity, Item
|
||||
from ..models_ticket import ItemLink, Notification, TicketExt
|
||||
from ..schemas import Pagination, encode_cursor, ok
|
||||
from ..serializers import item_out, tags_of
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
|
||||
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
|
||||
|
||||
MAX_REJECT_NOTE = 500
|
||||
|
||||
|
||||
# -- 入参 ---------------------------------------------------------------
|
||||
class PromoteIn(BaseModel):
|
||||
assignee_id: uuid.UUID
|
||||
title: str | None = Field(default=None, max_length=200)
|
||||
body_md: str | None = Field(default=None, max_length=20000)
|
||||
due_at: datetime | None = None
|
||||
priority: int | None = Field(default=None, ge=0, le=3)
|
||||
version: int | None = Field(default=None, description="原任务的版本,给了就做乐观锁")
|
||||
|
||||
|
||||
class ReassignIn(BaseModel):
|
||||
assignee_id: uuid.UUID
|
||||
|
||||
|
||||
class VerifyIn(BaseModel):
|
||||
approve: bool
|
||||
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
||||
resolution_md: str = Field(
|
||||
default="", max_length=20000, description="验收通过时写的结论,M3 用它长知识"
|
||||
)
|
||||
|
||||
|
||||
class ActionIn(BaseModel):
|
||||
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
||||
version: int | None = None
|
||||
|
||||
|
||||
class LinkIn(BaseModel):
|
||||
to_item_id: uuid.UUID
|
||||
link_type: str = "related"
|
||||
|
||||
|
||||
# -- 内部工具 -----------------------------------------------------------
|
||||
async def _load_item(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, kinds: tuple[str, ...]
|
||||
) -> Item:
|
||||
item = await session.get(Item, item_id)
|
||||
if item is None or item.workspace_id != ws_id or item.kind not in kinds:
|
||||
# 跨租户 / 不存在 / 类型不符:对外一律"不存在"
|
||||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
|
||||
if item.deleted_at is not None:
|
||||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
|
||||
return item
|
||||
|
||||
|
||||
async def _load_ext(session: AsyncSession, item_id: uuid.UUID) -> TicketExt:
|
||||
ext = await session.get(TicketExt, item_id)
|
||||
if ext is None:
|
||||
raise AppError(ErrorCode.TICKET_NOT_FOUND, "这不是一条工单")
|
||||
return ext
|
||||
|
||||
|
||||
async def _active_member_id(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
exists = (
|
||||
await session.execute(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == ws_id,
|
||||
Member.user_id == user_id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if exists is None:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
|
||||
|
||||
|
||||
async def _log(
|
||||
session: AsyncSession,
|
||||
item: Item,
|
||||
actor_id: uuid.UUID | None,
|
||||
action: str,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
session.add(
|
||||
Activity(
|
||||
workspace_id=item.workspace_id,
|
||||
item_id=item.id,
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
payload=payload or {},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _notify(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
kind: str,
|
||||
item_id: uuid.UUID | None,
|
||||
title: str,
|
||||
body: str = "",
|
||||
) -> None:
|
||||
session.add(
|
||||
Notification(
|
||||
workspace_id=ws_id,
|
||||
user_id=user_id,
|
||||
kind=kind,
|
||||
item_id=item_id,
|
||||
title=title[:120],
|
||||
body=body[:300],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _ticket_ext_out(ext: TicketExt) -> dict[str, Any]:
|
||||
return {
|
||||
"requester_id": str(ext.requester_id),
|
||||
"assignee_id": str(ext.assignee_id),
|
||||
"parent_item_id": str(ext.parent_item_id) if ext.parent_item_id else None,
|
||||
"accepted_at": ext.accepted_at,
|
||||
"submitted_at": ext.submitted_at,
|
||||
"verified_at": ext.verified_at,
|
||||
"verified_by": str(ext.verified_by) if ext.verified_by else None,
|
||||
"reject_count": ext.reject_count,
|
||||
"resolution_md": ext.resolution_md,
|
||||
}
|
||||
|
||||
|
||||
async def _item_out(session: AsyncSession, ws_id: uuid.UUID, item: Item) -> dict[str, Any]:
|
||||
"""单条序列化。标签必须一起带上——前端把 `item.tags` 当数组用,
|
||||
少这个字段整页会白屏(M2 首版就是这么炸的)。"""
|
||||
grouped = await tags_of(session, ws_id, [item.id])
|
||||
return item_out(item, grouped.get(item.id))
|
||||
|
||||
|
||||
async def _items_out(
|
||||
session: AsyncSession, ws_id: uuid.UUID, items: list[Item]
|
||||
) -> list[dict[str, Any]]:
|
||||
grouped = await tags_of(session, ws_id, [i.id for i in items])
|
||||
return [item_out(i, grouped.get(i.id)) for i in items]
|
||||
|
||||
|
||||
async def _ticket_out(
|
||||
session: AsyncSession, ws_id: uuid.UUID, ticket: Item, ext: TicketExt
|
||||
) -> dict:
|
||||
return ok({**await _item_out(session, ws_id, ticket), "ticket": _ticket_ext_out(ext)})
|
||||
|
||||
|
||||
async def _patch_item(
|
||||
session: AsyncSession,
|
||||
item: Item,
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
expect_version: int | None,
|
||||
) -> None:
|
||||
"""带版本的 UPDATE:给了 version 就必须匹配,否则 409 让客户端刷新。"""
|
||||
where = [Item.id == item.id, Item.workspace_id == item.workspace_id]
|
||||
if expect_version is not None:
|
||||
where.append(Item.version == expect_version)
|
||||
result = await session.execute(
|
||||
update(Item)
|
||||
.where(*where)
|
||||
.values(**values, version=Item.version + 1, updated_at=datetime.now(timezone.utc))
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
await session.refresh(item)
|
||||
raise AppError(
|
||||
ErrorCode.VERSION_CONFLICT,
|
||||
"这条工单刚被改过,请刷新后重试",
|
||||
details={"current_version": item.version},
|
||||
)
|
||||
await session.refresh(item)
|
||||
|
||||
|
||||
# -- 升级 ---------------------------------------------------------------
|
||||
@router.post("/items/{item_id}/promote", summary="把任务升级成工单")
|
||||
async def promote(item_id: uuid.UUID, body: PromoteIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
task = await _load_item(session, ctx.ws_id, item_id, ("task",))
|
||||
if task.status in ("done", "canceled"):
|
||||
raise AppError(
|
||||
ErrorCode.ITEM_STATUS_INVALID,
|
||||
f"「{task.status}」的任务不能再升级成工单",
|
||||
details={"status": task.status},
|
||||
)
|
||||
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(TicketExt.item_id).where(
|
||||
TicketExt.workspace_id == ctx.ws_id, TicketExt.parent_item_id == task.id
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if existing is not None:
|
||||
raise AppError(ErrorCode.TICKET_ALREADY_EXISTS, "这条任务已经升级过工单了")
|
||||
|
||||
await _active_member_id(session, ctx.ws_id, body.assignee_id)
|
||||
|
||||
ticket = Item(
|
||||
workspace_id=ctx.ws_id,
|
||||
kind="ticket",
|
||||
title=(body.title or task.title)[:200],
|
||||
body_md=body.body_md if body.body_md is not None else task.body_md,
|
||||
status="open",
|
||||
priority=body.priority if body.priority is not None else task.priority,
|
||||
creator_id=ctx.user.id,
|
||||
assignee_id=body.assignee_id,
|
||||
due_at=body.due_at or task.due_at,
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.flush()
|
||||
|
||||
ext = TicketExt(
|
||||
item_id=ticket.id,
|
||||
workspace_id=ctx.ws_id,
|
||||
requester_id=ctx.user.id,
|
||||
assignee_id=body.assignee_id,
|
||||
parent_item_id=task.id,
|
||||
)
|
||||
session.add(ext)
|
||||
await session.flush()
|
||||
|
||||
# 原任务确实卡住了,进 blocked;不能静默把用户的待办改成别的状态
|
||||
await _patch_item(session, task, {"status": "blocked"}, expect_version=body.version)
|
||||
await _log(session, task, ctx.user.id, "promoted_to_ticket", {"ticket_id": str(ticket.id)})
|
||||
await _log(session, ticket, ctx.user.id, "ticket_created", {"parent_item_id": str(task.id)})
|
||||
await _notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_id=body.assignee_id,
|
||||
kind="ticket_assigned",
|
||||
item_id=ticket.id,
|
||||
title=f"你被指派了一条工单:{ticket.title}",
|
||||
body=f"{ctx.user.nickname or '同事'} 请你处理,完成后提交验收",
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ctx.ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="ticket.promote",
|
||||
target_type="item",
|
||||
target_id=str(ticket.id),
|
||||
payload={"parent_item_id": str(task.id)},
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
task_out, ticket_out = await _items_out(session, ctx.ws_id, [task, ticket])
|
||||
return ok({"task": task_out, "ticket": {**ticket_out, "ticket": _ticket_ext_out(ext)}})
|
||||
|
||||
|
||||
# -- 办理/验收 ----------------------------------------------------------
|
||||
async def _advance(
|
||||
ctx: TenantCtx,
|
||||
session: AsyncSession,
|
||||
item_id: uuid.UUID,
|
||||
*,
|
||||
action: str,
|
||||
note: str = "",
|
||||
approve: bool | None = None,
|
||||
resolution_md: str = "",
|
||||
version: int | None = None,
|
||||
) -> dict:
|
||||
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
|
||||
ext = await _load_ext(session, ticket.id)
|
||||
|
||||
target = target_status(action, ticket.status)
|
||||
ensure_can_act(
|
||||
action=action,
|
||||
actor_id=ctx.user.id,
|
||||
role=ctx.role,
|
||||
assignee_id=ext.assignee_id,
|
||||
requester_id=ext.requester_id,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
previous = ticket.status
|
||||
values: dict[str, Any] = {"status": target}
|
||||
if target == "done":
|
||||
values["completed_at"] = now
|
||||
ext.verified_at = now
|
||||
ext.verified_by = ctx.user.id
|
||||
if resolution_md:
|
||||
ext.resolution_md = resolution_md
|
||||
if target == "in_progress" and action == "reject":
|
||||
ext.reject_count = ext.reject_count + 1
|
||||
|
||||
stamp_field = NOW_FIELD.get(target)
|
||||
if stamp_field and action != "reject":
|
||||
setattr(ext, stamp_field, now)
|
||||
|
||||
await _patch_item(session, ticket, values, expect_version=version)
|
||||
|
||||
payload: dict[str, Any] = {"action": action, "from": previous, "to": target}
|
||||
if note:
|
||||
payload["note"] = note
|
||||
await _log(session, ticket, ctx.user.id, f"ticket_{action}", payload)
|
||||
|
||||
# 通知对家:验收找提需求的人,其余找办理人
|
||||
if action == "submit":
|
||||
await _notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_id=ext.requester_id,
|
||||
kind="ticket_submitted",
|
||||
item_id=ticket.id,
|
||||
title=f"工单待你验收:{ticket.title}",
|
||||
body=note or "办理人已提交,请确认结果",
|
||||
)
|
||||
elif action == "approve":
|
||||
await _notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_id=ext.assignee_id,
|
||||
kind="ticket_approved",
|
||||
item_id=ticket.id,
|
||||
title=f"工单已通过验收:{ticket.title}",
|
||||
body=note or "谢谢,这事结了",
|
||||
)
|
||||
elif action == "reject":
|
||||
await _notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_id=ext.assignee_id,
|
||||
kind="ticket_rejected",
|
||||
item_id=ticket.id,
|
||||
title=f"工单被退回:{ticket.title}",
|
||||
body=note or "请按反馈再改一版",
|
||||
)
|
||||
|
||||
# 验收通过 -> 原任务闭环回流
|
||||
returned = returned_task_status(target)
|
||||
if returned and ext.parent_item_id:
|
||||
parent = await session.get(Item, ext.parent_item_id)
|
||||
if parent is not None and parent.workspace_id == ctx.ws_id and parent.status == "blocked":
|
||||
parent.status = returned
|
||||
parent.version += 1
|
||||
parent.updated_at = now
|
||||
await _log(
|
||||
session,
|
||||
parent,
|
||||
ctx.user.id,
|
||||
"ticket_done",
|
||||
{"ticket_id": str(ticket.id), "status": returned},
|
||||
)
|
||||
await _notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_id=parent.creator_id,
|
||||
kind="task_unblocked",
|
||||
item_id=parent.id,
|
||||
title=f"任务可以继续了:{parent.title}",
|
||||
body="它对应的工单已验收通过",
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/ticket/accept", summary="接受工单")
|
||||
async def accept(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
return await _advance(ctx, session, item_id, action="accept")
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/ticket/start", summary="开始办理")
|
||||
async def start(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
return await _advance(ctx, session, item_id, action="start")
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/ticket/submit", summary="提交验收")
|
||||
async def submit(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
return await _advance(
|
||||
ctx, session, item_id, action="submit", note=body.note, version=body.version
|
||||
)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/ticket/verify", summary="验收:通过或驳回")
|
||||
async def verify(item_id: uuid.UUID, body: VerifyIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
return await _advance(
|
||||
ctx,
|
||||
session,
|
||||
item_id,
|
||||
action="approve" if body.approve else "reject",
|
||||
note=body.note,
|
||||
approve=body.approve,
|
||||
resolution_md=body.resolution_md,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/ticket/cancel", summary="取消工单")
|
||||
async def cancel(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
return await _advance(
|
||||
ctx, session, item_id, action="cancel", note=body.note, version=body.version
|
||||
)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/ticket/reassign", summary="改派给同事")
|
||||
async def reassign(
|
||||
item_id: uuid.UUID, body: ReassignIn, ctx: MemberDep, session: SessionDep
|
||||
) -> dict:
|
||||
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
|
||||
ext = await _load_ext(session, ticket.id)
|
||||
|
||||
ensure_can_act(
|
||||
action="reassign",
|
||||
actor_id=ctx.user.id,
|
||||
role=ctx.role,
|
||||
assignee_id=ext.assignee_id,
|
||||
requester_id=ext.requester_id,
|
||||
)
|
||||
if ticket.status not in CANCELABLE:
|
||||
raise AppError(
|
||||
ErrorCode.TICKET_STATUS_INVALID,
|
||||
f"「{ticket.status}」的工单不能改派",
|
||||
details={"allowed_from": list(CANCELABLE)},
|
||||
)
|
||||
if body.assignee_id == ext.assignee_id:
|
||||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||||
|
||||
await _active_member_id(session, ctx.ws_id, body.assignee_id)
|
||||
|
||||
await _patch_item(
|
||||
session,
|
||||
ticket,
|
||||
{"status": "open", "assignee_id": body.assignee_id, "completed_at": None},
|
||||
expect_version=None,
|
||||
)
|
||||
ext.assignee_id = body.assignee_id
|
||||
ext.accepted_at = None
|
||||
ext.submitted_at = None
|
||||
await _log(session, ticket, ctx.user.id, "ticket_reassigned", {"to": str(body.assignee_id)})
|
||||
await _notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_id=body.assignee_id,
|
||||
kind="ticket_assigned",
|
||||
item_id=ticket.id,
|
||||
title=f"转给你一条工单:{ticket.title}",
|
||||
body="请先接收,再开始办理",
|
||||
)
|
||||
await session.flush()
|
||||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||||
|
||||
|
||||
# -- 工单列表 -----------------------------------------------------------
|
||||
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
|
||||
async def list_tickets(
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
role: Annotated[str, Query(description="assigned|requested|all")] = "assigned",
|
||||
status: Annotated[list[str] | None, Query(description="按状态过滤,可重复")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[str | None, Query()] = None,
|
||||
) -> dict:
|
||||
page = Pagination(limit=limit, cursor=cursor)
|
||||
limit, parsed_cursor = page.parsed()
|
||||
|
||||
stmt: Select = (
|
||||
select(Item, TicketExt)
|
||||
.join(TicketExt, TicketExt.item_id == Item.id)
|
||||
.where(Item.workspace_id == ctx.ws_id, Item.kind == "ticket", Item.deleted_at.is_(None))
|
||||
)
|
||||
if role == "assigned":
|
||||
stmt = stmt.where(TicketExt.assignee_id == ctx.user.id)
|
||||
elif role == "requested":
|
||||
stmt = stmt.where(TicketExt.requester_id == ctx.user.id)
|
||||
elif role != "all":
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "role 只能是 assigned / requested / all")
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(Item.status.in_(status))
|
||||
|
||||
stmt = stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
|
||||
if parsed_cursor is not None:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Item.updated_at < parsed_cursor.created_at,
|
||||
and_(Item.updated_at == parsed_cursor.created_at, Item.id < parsed_cursor.id),
|
||||
)
|
||||
)
|
||||
|
||||
rows = (await session.execute(stmt)).all()
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
next_cursor = (
|
||||
encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None
|
||||
)
|
||||
tickets = await _items_out(session, ctx.ws_id, [item for item, _ in rows])
|
||||
return ok(
|
||||
{
|
||||
"tickets": [
|
||||
{**out, "ticket": _ticket_ext_out(ext)}
|
||||
for out, (_, ext) in zip(tickets, rows, strict=True)
|
||||
],
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": has_more,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# -- 事项关系(子任务 / 相关)------------------------------------------
|
||||
@router.get("/items/{item_id}/links", summary="这条事项的子项与相关项")
|
||||
async def list_links(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||||
|
||||
# parent 边的方向:from 是 to 的子项。所以"我的子项"= 指向我、且类型为 parent 的 from。
|
||||
children = (
|
||||
await session.execute(
|
||||
select(ItemLink, Item)
|
||||
.join(Item, Item.id == ItemLink.from_item_id)
|
||||
.where(
|
||||
ItemLink.workspace_id == ctx.ws_id,
|
||||
ItemLink.to_item_id == item_id,
|
||||
ItemLink.link_type == "parent",
|
||||
)
|
||||
)
|
||||
).all()
|
||||
parents = (
|
||||
await session.execute(
|
||||
select(ItemLink, Item)
|
||||
.join(Item, Item.id == ItemLink.to_item_id)
|
||||
.where(
|
||||
ItemLink.workspace_id == ctx.ws_id,
|
||||
ItemLink.from_item_id == item_id,
|
||||
ItemLink.link_type == "parent",
|
||||
)
|
||||
)
|
||||
).all()
|
||||
related = (
|
||||
await session.execute(
|
||||
select(ItemLink, Item)
|
||||
.join(
|
||||
Item,
|
||||
or_(
|
||||
and_(ItemLink.from_item_id == item_id, Item.id == ItemLink.to_item_id),
|
||||
and_(ItemLink.to_item_id == item_id, Item.id == ItemLink.from_item_id),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
ItemLink.workspace_id == ctx.ws_id,
|
||||
ItemLink.link_type == "related",
|
||||
or_(ItemLink.from_item_id == item_id, ItemLink.to_item_id == item_id),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
async def _pack(rows: list[Any]) -> list[dict[str, Any]]:
|
||||
packed = await _items_out(session, ctx.ws_id, [item for _, item in rows])
|
||||
return [
|
||||
{"link_id": str(link.id), "link_type": link.link_type, "item": out}
|
||||
for (link, _), out in zip(rows, packed, strict=True)
|
||||
]
|
||||
|
||||
return ok(
|
||||
{
|
||||
"children": await _pack(children),
|
||||
"parents": await _pack(parents),
|
||||
"related": await _pack(related),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/links", summary="把另一条事项挂成子项/相关项")
|
||||
async def create_link(
|
||||
item_id: uuid.UUID, body: LinkIn, ctx: MemberDep, session: SessionDep
|
||||
) -> dict:
|
||||
if body.link_type not in ("parent", "related"):
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "link_type 只能是 parent / related")
|
||||
if body.to_item_id == item_id:
|
||||
raise AppError(ErrorCode.LINK_CONFLICT, "不能把自己挂到自己下面")
|
||||
|
||||
await _load_item(session, ctx.ws_id, body.to_item_id, ("task", "ticket"))
|
||||
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||||
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ItemLink).where(
|
||||
ItemLink.from_item_id == body.to_item_id,
|
||||
ItemLink.to_item_id == item_id,
|
||||
ItemLink.link_type == body.link_type,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise AppError(ErrorCode.LINK_CONFLICT, "这条关系已经存在")
|
||||
|
||||
link = ItemLink(
|
||||
workspace_id=ctx.ws_id,
|
||||
from_item_id=body.to_item_id,
|
||||
to_item_id=item_id,
|
||||
link_type=body.link_type,
|
||||
)
|
||||
session.add(link)
|
||||
await session.flush()
|
||||
return ok({"id": str(link.id), "link_type": link.link_type})
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}/links/{link_id}", summary="解除关系")
|
||||
async def delete_link(
|
||||
item_id: uuid.UUID, link_id: uuid.UUID, ctx: MemberDep, session: SessionDep
|
||||
) -> dict:
|
||||
link = await session.get(ItemLink, link_id)
|
||||
if (
|
||||
link is None
|
||||
or link.workspace_id != ctx.ws_id
|
||||
or item_id
|
||||
not in (
|
||||
link.from_item_id,
|
||||
link.to_item_id,
|
||||
)
|
||||
):
|
||||
raise AppError(ErrorCode.LINK_NOT_FOUND, "关系不存在")
|
||||
await session.delete(link)
|
||||
return ok({"deleted": True})
|
||||
|
||||
|
||||
# -- 通知 ---------------------------------------------------------------
|
||||
def _notification_out(n: Notification, item_title: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(n.id),
|
||||
"kind": n.kind,
|
||||
"item_id": str(n.item_id) if n.item_id else None,
|
||||
"item_title": item_title,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"read_at": n.read_at,
|
||||
"created_at": n.created_at,
|
||||
}
|
||||
|
||||
|
||||
@notify_router.get("", summary="我的通知(默认只看未读)")
|
||||
async def list_notifications(
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
unread_only: bool = True,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 30,
|
||||
cursor: Annotated[str | None, Query()] = None,
|
||||
) -> dict:
|
||||
page = Pagination(limit=limit, cursor=cursor)
|
||||
limit, parsed_cursor = page.parsed()
|
||||
|
||||
stmt = select(Notification).where(
|
||||
Notification.workspace_id == ctx.ws_id, Notification.user_id == ctx.user.id
|
||||
)
|
||||
if unread_only:
|
||||
stmt = stmt.where(Notification.read_at.is_(None))
|
||||
stmt = stmt.order_by(Notification.created_at.desc(), Notification.id.desc()).limit(limit + 1)
|
||||
if parsed_cursor is not None:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Notification.created_at < parsed_cursor.created_at,
|
||||
and_(
|
||||
Notification.created_at == parsed_cursor.created_at,
|
||||
Notification.id < parsed_cursor.id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
has_more = len(rows) > limit
|
||||
rows = list(rows[:limit])
|
||||
|
||||
titles: dict[uuid.UUID, str] = {}
|
||||
item_ids = [n.item_id for n in rows if n.item_id]
|
||||
if item_ids:
|
||||
for item_id, title in (
|
||||
await session.execute(select(Item.id, Item.title).where(Item.id.in_(item_ids)))
|
||||
).all():
|
||||
titles[item_id] = title
|
||||
|
||||
next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if has_more and rows else None
|
||||
return ok(
|
||||
{
|
||||
"notifications": [
|
||||
_notification_out(n, titles.get(n.item_id) if n.item_id else None) for n in rows
|
||||
],
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": has_more,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@notify_router.get("/unread-count", summary="未读数(导航角标用)")
|
||||
async def unread_count(ctx: MemberDep, session: SessionDep) -> dict:
|
||||
from sqlalchemy import func as sa_func
|
||||
|
||||
total = (
|
||||
await session.execute(
|
||||
select(sa_func.count(Notification.id)).where(
|
||||
Notification.workspace_id == ctx.ws_id,
|
||||
Notification.user_id == ctx.user.id,
|
||||
Notification.read_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
return ok({"unread": int(total)})
|
||||
|
||||
|
||||
@notify_router.post("/{notification_id}/read", summary="标记已读")
|
||||
async def mark_read(notification_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
n = await session.get(Notification, notification_id)
|
||||
if n is None or n.workspace_id != ctx.ws_id or n.user_id != ctx.user.id:
|
||||
raise AppError(ErrorCode.NOTIFICATION_NOT_FOUND, "通知不存在")
|
||||
if n.read_at is None:
|
||||
n.read_at = datetime.now(timezone.utc)
|
||||
return ok({"read_at": n.read_at})
|
||||
|
||||
|
||||
@notify_router.post("/read-all", summary="全部标记已读")
|
||||
async def mark_all_read(ctx: MemberDep, session: SessionDep) -> dict:
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
result = await session.execute(
|
||||
sa_update(Notification)
|
||||
.where(
|
||||
Notification.workspace_id == ctx.ws_id,
|
||||
Notification.user_id == ctx.user.id,
|
||||
Notification.read_at.is_(None),
|
||||
)
|
||||
.values(read_at=datetime.now(timezone.utc))
|
||||
)
|
||||
return ok({"marked": result.rowcount})
|
||||
|
||||
|
||||
__all__ = ["notify_router", "router"]
|
||||
@@ -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
|
||||
@@ -40,6 +40,15 @@ class ErrorCode(IntEnum):
|
||||
ITEM_NOT_FOUND = 5001
|
||||
ITEM_STATUS_INVALID = 5002
|
||||
TAG_NOT_FOUND = 5003
|
||||
LINK_NOT_FOUND = 5004
|
||||
LINK_CONFLICT = 5005
|
||||
|
||||
# 6xxx 工单域
|
||||
TICKET_NOT_FOUND = 6001
|
||||
TICKET_ALREADY_EXISTS = 6002
|
||||
TICKET_STATUS_INVALID = 6003
|
||||
TICKET_FORBIDDEN = 6004
|
||||
NOTIFICATION_NOT_FOUND = 6005
|
||||
|
||||
|
||||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||||
@@ -67,6 +76,13 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
ErrorCode.ITEM_NOT_FOUND: 404,
|
||||
ErrorCode.ITEM_STATUS_INVALID: 409,
|
||||
ErrorCode.TAG_NOT_FOUND: 404,
|
||||
ErrorCode.LINK_NOT_FOUND: 404,
|
||||
ErrorCode.LINK_CONFLICT: 409,
|
||||
ErrorCode.TICKET_NOT_FOUND: 404,
|
||||
ErrorCode.TICKET_ALREADY_EXISTS: 409,
|
||||
ErrorCode.TICKET_STATUS_INVALID: 409,
|
||||
ErrorCode.TICKET_FORBIDDEN: 403,
|
||||
ErrorCode.NOTIFICATION_NOT_FOUND: 404,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from .api import auth, items, workspaces
|
||||
from .api import auth, items, tickets, workspaces
|
||||
from .config import get_settings
|
||||
from .db import SessionFactory, current_app_role, engine
|
||||
from .errors import AppError, ErrorCode, http_status_for
|
||||
@@ -102,6 +102,8 @@ app.include_router(auth.router, prefix=settings.api_prefix)
|
||||
app.include_router(workspaces.router, prefix=settings.api_prefix)
|
||||
app.include_router(items.router, prefix=settings.api_prefix)
|
||||
app.include_router(items.tag_router, prefix=settings.api_prefix)
|
||||
app.include_router(tickets.router, prefix=settings.api_prefix)
|
||||
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||
|
||||
@@ -133,9 +133,10 @@ class AuditLog(Base):
|
||||
|
||||
# 需要 RLS 的租户表(迁移与测试共用这一份清单,避免漏加)
|
||||
#
|
||||
# 任务域的表在 models_task.py —— 它 import 本模块的 Base,所以只能在文件末尾反向 import,
|
||||
# 否则循环导入。
|
||||
# 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base,
|
||||
# 所以只能在文件末尾反向 import,否则循环导入。
|
||||
from .models_task import TASK_TENANT_TABLES # noqa: E402
|
||||
from .models_ticket import TICKET_TENANT_TABLES # noqa: E402
|
||||
|
||||
TENANT_TABLES: tuple[str, ...] = (
|
||||
"workspace",
|
||||
@@ -143,4 +144,5 @@ TENANT_TABLES: tuple[str, ...] = (
|
||||
"invitation",
|
||||
"audit_log",
|
||||
*TASK_TENANT_TABLES,
|
||||
*TICKET_TENANT_TABLES,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""工单域模型(M2):ticket_ext / item_link / notification。
|
||||
|
||||
三条设计约束(对应 PRODUCT-PLAN 的 D1/D2):
|
||||
|
||||
1. **工单不是新表,是 `item` 的一个 kind**。标题、正文、截止、标签、评论、事件流
|
||||
全部复用,M1 写的每一处代码对工单依然成立;`ticket_ext` 只补"谁请谁、验没验收"。
|
||||
2. **升级不是搬家**。原任务留在原地并进入 `blocked`(它确实被卡住了),工单是**新建**的一条
|
||||
item,通过 `TicketExt.parent_item_id` 指回去。工单验收通过时把原任务从 blocked 拉回 todo,
|
||||
这就是"闭环回流"——任务那条时间线完整保留,历史不被搬来搬去。
|
||||
3. **通知只记录"发生了什么",不做已读以外的状态机**。通知看错、漏看是常态,
|
||||
真正的状态永远以 item 为准,通知只是提醒,不能变成第二份真相。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .models import Base, Timestamps, new_uuid
|
||||
|
||||
# 工单状态机(与任务共用 item.status 这一列,但取值集合不同)
|
||||
TICKET_STATUSES: tuple[str, ...] = (
|
||||
"open",
|
||||
"accepted",
|
||||
"in_progress",
|
||||
"pending_verify",
|
||||
"done",
|
||||
"canceled",
|
||||
)
|
||||
|
||||
LINK_TYPES: tuple[str, ...] = ("parent", "related")
|
||||
|
||||
|
||||
class TicketExt(Base, Timestamps):
|
||||
"""工单的附加信息,与 item 一比一。"""
|
||||
|
||||
__tablename__ = "ticket_ext"
|
||||
__table_args__ = (
|
||||
Index("ix_ticket_ws_assignee", "workspace_id", "assignee_id"),
|
||||
Index("ix_ticket_ws_requester", "workspace_id", "requester_id"),
|
||||
CheckConstraint("reject_count >= 0", name="ck_ticket_reject_count"),
|
||||
)
|
||||
|
||||
# item_id 既是主键又是外键:一条 item 最多一条 ticket_ext,天然 1:1
|
||||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# 谁提的这件事(下单的人)
|
||||
requester_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
# 谁在办(冗余一份,免得每次查工单都要回 item 表;两处必须一致,改指派时同时写)
|
||||
assignee_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
# 从哪条任务升级来的;独立创建的工单为空
|
||||
parent_item_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
|
||||
)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
verified_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||
)
|
||||
# 被驳回次数:不是为了考核,是为了让提需求的人知道"这事返工过几次"
|
||||
reject_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 办完的结论:写什么由办理人决定,M3 从这里长知识草稿
|
||||
resolution_md: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
||||
|
||||
class ItemLink(Base, Timestamps):
|
||||
"""事项之间的关系。M2 只做两种:父子(子任务)与相关。
|
||||
|
||||
`from_item_id → to_item_id` 是有方向的:parent 表示 from 是 to 的子项。
|
||||
"""
|
||||
|
||||
__tablename__ = "item_link"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("from_item_id", "to_item_id", "link_type", name="uq_item_link"),
|
||||
CheckConstraint("from_item_id <> to_item_id", name="ck_item_link_not_self"),
|
||||
CheckConstraint("link_type IN ('parent','related')", name="ck_item_link_type"),
|
||||
Index("ix_item_link_ws_from", "workspace_id", "from_item_id"),
|
||||
Index("ix_item_link_ws_to", "workspace_id", "to_item_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
from_item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
to_item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
link_type: Mapped[str] = mapped_column(String(16), nullable=False, default="related")
|
||||
|
||||
|
||||
class Notification(Base, Timestamps):
|
||||
"""站内通知。收件人维度,不是工作区里的公共消息流。"""
|
||||
|
||||
__tablename__ = "notification"
|
||||
__table_args__ = (
|
||||
Index("ix_notification_ws_user_read", "workspace_id", "user_id", "read_at"),
|
||||
Index("ix_notification_ws_created", "workspace_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
item_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE")
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
body: Mapped[str] = mapped_column(String(300), nullable=False, default="")
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
TICKET_TENANT_TABLES: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""统一的 item / tag 序列化。
|
||||
|
||||
为什么要单独一份:工单接口最初自己拼了一份 dict,忘了 `tags` 字段,
|
||||
前端列表把 `item.tags` 当数组用,一升级成工单就整页白屏。
|
||||
任何返回 item 的接口都必须用这里的 `item_out`,并用 `tags_of` 把标签一起取回来。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models_task import Item, ItemTag, Tag
|
||||
|
||||
|
||||
def tag_out(tag: Tag) -> dict[str, Any]:
|
||||
return {"id": str(tag.id), "name": tag.name, "color": tag.color}
|
||||
|
||||
|
||||
def item_out(item: Item, tags: list[Tag] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(item.id),
|
||||
"kind": item.kind,
|
||||
"title": item.title,
|
||||
"body_md": item.body_md,
|
||||
"status": item.status,
|
||||
"priority": item.priority,
|
||||
"creator_id": str(item.creator_id),
|
||||
"assignee_id": str(item.assignee_id) if item.assignee_id else None,
|
||||
"due_at": item.due_at,
|
||||
"completed_at": item.completed_at,
|
||||
"sort_order": item.sort_order,
|
||||
"deleted_at": item.deleted_at,
|
||||
"tags": [tag_out(t) for t in (tags or [])],
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
"version": item.version,
|
||||
}
|
||||
|
||||
|
||||
async def tags_of(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
||||
) -> dict[uuid.UUID, list[Tag]]:
|
||||
"""一次把这些事项的标签查回来(别在循环里逐条查)。"""
|
||||
if not item_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(ItemTag.item_id, Tag)
|
||||
.join(Tag, Tag.id == ItemTag.tag_id)
|
||||
.where(ItemTag.workspace_id == ws_id, ItemTag.item_id.in_(item_ids))
|
||||
.order_by(Tag.name)
|
||||
)
|
||||
).all()
|
||||
grouped: dict[uuid.UUID, list[Tag]] = {}
|
||||
for item_id, tag in rows:
|
||||
grouped.setdefault(item_id, []).append(tag)
|
||||
return grouped
|
||||
Reference in New Issue
Block a user