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:
@@ -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")
|
||||
Reference in New Issue
Block a user