"""工单域模型(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")