Kaynağa Gözat

feat(backend): CC task read status in oa_engine.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 ay önce
ebeveyn
işleme
7f8c163c47

+ 101 - 0
backend/app/services/oa_engine.py

@@ -829,6 +829,12 @@ def _task_to_dict(t: OaTask, *, assignee_user_name: str = "") -> Dict[str, Any]:
         "commentText": t.comment_text or "",
         "resolveSource": t.resolve_source or "",
         "signMode": t.sign_mode or "",
+        "isRead": bool(int(getattr(t, "is_read", 0) or 0)),
+        "readTime": (
+            t.read_time.strftime("%Y-%m-%d %H:%M:%S")
+            if getattr(t, "read_time", None)
+            else ""
+        ),
         "createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
         "updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
     }
@@ -1479,6 +1485,8 @@ def _create_tasks_for_node(
             comment_text=None,
             resolve_source=resolve_source,
             sign_mode=sign_mode if task_type == "approval" else None,
+            is_read=0,
+            read_time=None,
             round_no=int(getattr(inst, "current_round", None) or 1),
             tenant_no=inst.tenant_no,
             order_num=seq,
@@ -1928,12 +1936,99 @@ def get_instance_detail(db: Session, instance_id: str, *, tenant_no: str) -> Dic
     return data
 
 
+def _normalize_cc_read_status(raw: Optional[str]) -> str:
+    v = (raw or "").strip().lower() or "unread"
+    if v not in ("unread", "read", "all"):
+        raise OaEngineError("readStatus 仅支持 unread、read、all")
+    return v
+
+
+def _apply_cc_task_read(
+    db: Session,
+    *,
+    task: OaTask,
+    user_id: str,
+    now: datetime,
+) -> bool:
+    """将抄送任务标为已读。返回是否发生了写入。幂等时返回 False。"""
+    if int(getattr(task, "is_read", 0) or 0) == 1:
+        return False
+    task.is_read = 1
+    task.read_time = now
+    task.update_by = user_id
+    task.update_time = now
+    db.add(task)
+    return True
+
+
+def count_cc_unread(db: Session, *, tenant_no: str, user_id: str) -> int:
+    return (
+        db.query(OaTask)
+        .filter(
+            OaTask.tenant_no == tenant_no,
+            OaTask.del_flag == 0,
+            OaTask.assignee_user_id == user_id,
+            OaTask.task_type == "cc",
+            OaTask.is_read == 0,
+        )
+        .count()
+    )
+
+
+def mark_cc_task_read(
+    db: Session, *, tenant_no: str, user_id: str, task_id: str
+) -> Dict[str, Any]:
+    task = (
+        db.query(OaTask)
+        .filter(
+            OaTask.id == task_id,
+            OaTask.tenant_no == tenant_no,
+            OaTask.del_flag == 0,
+        )
+        .first()
+    )
+    if not task or task.task_type != "cc" or task.assignee_user_id != user_id:
+        raise OaEngineError("抄送任务不存在或无权操作")
+    now = datetime.now()
+    _apply_cc_task_read(db, task=task, user_id=user_id, now=now)
+    db.commit()
+    db.refresh(task)
+    return _task_to_dict(task)
+
+
+def mark_cc_tasks_read_batch(
+    db: Session, *, tenant_no: str, user_id: str, ids: List[str]
+) -> Dict[str, Any]:
+    id_list = [x.strip() for x in (ids or []) if (x or "").strip()]
+    if not id_list:
+        return {"updated": 0}
+    rows = (
+        db.query(OaTask)
+        .filter(
+            OaTask.tenant_no == tenant_no,
+            OaTask.del_flag == 0,
+            OaTask.assignee_user_id == user_id,
+            OaTask.task_type == "cc",
+            OaTask.id.in_(id_list),
+        )
+        .all()
+    )
+    now = datetime.now()
+    updated = 0
+    for t in rows:
+        if _apply_cc_task_read(db, task=t, user_id=user_id, now=now):
+            updated += 1
+    db.commit()
+    return {"updated": updated}
+
+
 def list_tasks(
     db: Session,
     *,
     tenant_no: str,
     user_id: str,
     kind: str,
+    read_status: Optional[str] = None,
 ) -> List[Dict[str, Any]]:
     kind = (kind or "").strip()
     if kind == "pending":
@@ -1959,6 +2054,12 @@ def list_tasks(
             OaTask.assignee_user_id == user_id,
             OaTask.task_type == "cc",
         )
+        rs = _normalize_cc_read_status(read_status)
+        if rs == "unread":
+            q = q.filter(OaTask.is_read == 0)
+        elif rs == "read":
+            q = q.filter(OaTask.is_read == 1)
+        # all: 不加 is_read 条件
     elif kind == "mine":
         insts = (
             db.query(OaInstance)

+ 83 - 0
backend/tests/test_oa_cc_read.py

@@ -0,0 +1,83 @@
+"""OA 抄送已读:序列化、筛选语义、标记幂等"""
+import unittest
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+from app.services import oa_engine as eng
+
+
+class TestTaskToDictIsRead(unittest.TestCase):
+    def test_includes_is_read_and_read_time(self):
+        t = SimpleNamespace(
+            id="t1",
+            instance_id="i1",
+            node_key="n1",
+            node_name="抄送",
+            task_type="cc",
+            assignee_user_id="u1",
+            status="done",
+            comment_text=None,
+            resolve_source=None,
+            sign_mode=None,
+            is_read=1,
+            read_time=datetime(2026, 8, 6, 12, 0, 0),
+            create_time=datetime(2026, 8, 6, 10, 0, 0),
+            update_time=datetime(2026, 8, 6, 11, 0, 0),
+        )
+        d = eng._task_to_dict(t)
+        self.assertEqual(d["isRead"], True)
+        self.assertEqual(d["readTime"], "2026-08-06 12:00:00")
+
+    def test_unread_defaults(self):
+        t = SimpleNamespace(
+            id="t2",
+            instance_id="i1",
+            node_key="n1",
+            node_name="抄送",
+            task_type="cc",
+            assignee_user_id="u1",
+            status="done",
+            comment_text=None,
+            resolve_source=None,
+            sign_mode=None,
+            is_read=0,
+            read_time=None,
+            create_time=None,
+            update_time=None,
+        )
+        d = eng._task_to_dict(t)
+        self.assertEqual(d["isRead"], False)
+        self.assertEqual(d["readTime"], "")
+
+
+class TestMarkCcReadHelpers(unittest.TestCase):
+    def test_normalize_read_status(self):
+        self.assertEqual(eng._normalize_cc_read_status(None), "unread")
+        self.assertEqual(eng._normalize_cc_read_status(""), "unread")
+        self.assertEqual(eng._normalize_cc_read_status("all"), "all")
+        self.assertEqual(eng._normalize_cc_read_status("read"), "read")
+        with self.assertRaises(eng.OaEngineError):
+            eng._normalize_cc_read_status("weird")
+
+    def test_mark_idempotent_keeps_read_time(self):
+        first = datetime(2026, 8, 1, 9, 0, 0)
+        task = SimpleNamespace(
+            id="t1",
+            task_type="cc",
+            assignee_user_id="u1",
+            del_flag=0,
+            is_read=1,
+            read_time=first,
+            update_by=None,
+            update_time=None,
+        )
+        db = MagicMock()
+        out = eng._apply_cc_task_read(db, task=task, user_id="u1", now=datetime(2026, 8, 6, 12, 0, 0))
+        self.assertFalse(out)  # 未发生更新
+        self.assertEqual(task.read_time, first)
+        self.assertEqual(task.is_read, 1)
+
+
+if __name__ == "__main__":
+    unittest.main()