Files
DadaoTask/backend/app/models_ticket.py
T
2026-11-10 13:47:56 +08:00

160 lines
6.9 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.
"""工单域模型(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,
text,
)
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"),
# 到期/逾期提醒由后台循环生成,可能被重复触发;带 dedupe_key 的写入用
# ON CONFLICT DO NOTHING,把"同一件事只提醒一次"交给数据库保证。
Index(
"uq_notification_dedupe",
"workspace_id",
"user_id",
"dedupe_key",
unique=True,
postgresql_where=text("dedupe_key IS NOT NULL"),
),
)
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))
# 提醒类通知的幂等键(如 task_due_soon:<item>:<due_at>)。
# 普通通知为空,不参与唯一约束。
dedupe_key: Mapped[str | None] = mapped_column(String(120))
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")