Bladeren bron

feat(oa): add transfer_task to reassign pending approval

Close the current assignee's task as transferred and create a same-node pending task for the target user; include transferred in done lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 maanden geleden
bovenliggende
commit
aaff660d56
2 gewijzigde bestanden met toevoegingen van 415 en 8 verwijderingen
  1. 115 5
      backend/app/services/oa_engine.py
  2. 300 3
      backend/tests/test_oa_engine.py

+ 115 - 5
backend/app/services/oa_engine.py

@@ -475,7 +475,7 @@ def build_progress_items(
 ) -> List[Dict[str, Any]]:
     """投影审批进度:发起人 → 树序 tasks(同节点多人合并)→ 未到达;终态收尾。
 
-    钉钉对齐:打回结果落在审批任务节点上,不另插 kind=return;不做同单多轮分段。
+    钉钉对齐:打回结果落在审批任务节点上,不另插 kind=return / terminal 终态「已打回」行;不做同单多轮分段。
     或签/会签:同一 nodeKey 只出一张卡片,people 列出各审批人及状态。
     进行中:按命中路径把后续审批/抄送节点全部挂为未到达(尽量解析人员)。
     """
@@ -517,7 +517,7 @@ def build_progress_items(
         acted = [
             p
             for p in people
-            if p["status"] in ("approved", "rejected", "returned", "completed")
+            if p["status"] in ("approved", "rejected", "returned", "completed", "transferred")
         ]
         ts_src = acted or people
         timestamp = ""
@@ -593,8 +593,7 @@ def build_progress_items(
         items.append(_terminal_item("rejected", "流程结束(已拒绝)", inst))
     elif inst.status == "cancelled":
         items.append(_terminal_item("cancelled", "已撤销", inst))
-    elif inst.status == "returned":
-        items.append(_terminal_item("returned", "已打回", inst))
+    # returned:打回结果只落在审批任务节点上,不再追加独立终态「已打回」行
     return items
 
 
@@ -1603,7 +1602,7 @@ def list_tasks(
             OaTask.del_flag == 0,
             OaTask.assignee_user_id == user_id,
             OaTask.task_type == "approval",
-            OaTask.status.in_(("approved", "rejected", "returned")),
+            OaTask.status.in_(("approved", "rejected", "returned", "transferred")),
         )
     elif kind == "cc":
         q = db.query(OaTask).filter(
@@ -1882,6 +1881,117 @@ def add_sign(
     return get_instance_detail(db, inst.id, tenant_no=tenant_no)
 
 
+def transfer_task(
+    db: Session,
+    task_id: str,
+    *,
+    tenant_no: str,
+    user_id: str,
+    target_user_id: str,
+    comment: Optional[str] = None,
+) -> Dict[str, Any]:
+    """当前待办处理人转交:结束本人待办并为同节点目标用户新建 pending 任务。"""
+    task = (
+        db.query(OaTask)
+        .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0)
+        .first()
+    )
+    if not task:
+        raise OaEngineError("任务不存在")
+    if task.assignee_user_id != user_id:
+        raise OaEngineError("无权转交该任务")
+    if task.task_type != "approval" or task.status != "pending":
+        raise OaEngineError("仅待处理的审批任务可转交")
+
+    inst = _get_instance(db, task.instance_id, tenant_no)
+    if inst.status != "running":
+        raise OaEngineError("实例已结束")
+
+    target = (target_user_id or "").strip()
+    if not target:
+        raise OaEngineError("请选择转交人")
+    if target == user_id:
+        raise OaEngineError("不能转交给自己")
+
+    u = (
+        db.query(SysUser)
+        .filter(
+            SysUser.id == target,
+            SysUser.tenant_no == tenant_no,
+            SysUser.del_flag == 0,
+            SysUser.status == "1",
+        )
+        .first()
+    )
+    if not u:
+        raise OaEngineError(f"转交用户无效:{target}")
+
+    existing = {
+        r.assignee_user_id
+        for r in db.query(OaTask)
+        .filter(
+            OaTask.instance_id == inst.id,
+            OaTask.node_key == task.node_key,
+            OaTask.del_flag == 0,
+            OaTask.status == "pending",
+        )
+        .all()
+    }
+    if target in existing:
+        raise OaEngineError("该用户已在当前节点待办中")
+
+    now = datetime.now()
+    comment_text = (comment or "").strip() or None
+    round_no = int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1)
+
+    task.status = "transferred"
+    task.comment_text = comment_text
+    task.update_by = user_id
+    task.update_time = now
+    db.add(task)
+
+    next_order = _next_task_order_num(db, inst.id)
+    db.add(
+        OaTask(
+            id=generate_uuid(),
+            instance_id=inst.id,
+            node_key=task.node_key,
+            node_name=task.node_name,
+            task_type="approval",
+            assignee_user_id=target,
+            status="pending",
+            comment_text=None,
+            resolve_source="transfer",
+            sign_mode=task.sign_mode or "or",
+            round_no=round_no,
+            tenant_no=tenant_no,
+            order_num=next_order,
+            remark=comment_text,
+            create_by=user_id,
+            create_dept_id=inst.initiator_dept_id or "",
+            create_time=now,
+            update_by=user_id,
+            update_time=now,
+            del_flag=0,
+        )
+    )
+
+    event_comment = comment_text or f"转交给 {target}"
+    _add_instance_event(
+        db,
+        inst=inst,
+        event_type="transfer",
+        actor_user_id=user_id,
+        round_no=round_no,
+        comment=event_comment,
+        task_id=task.id,
+        node_key=task.node_key,
+    )
+
+    db.commit()
+    return get_instance_detail(db, inst.id, tenant_no=tenant_no)
+
+
 def return_task(
     db: Session,
     task_id: str,

+ 300 - 3
backend/tests/test_oa_engine.py

@@ -689,7 +689,11 @@ class TestBuildProgress(unittest.TestCase):
             pending = [x for x in items if x["kind"] == "pending_node"]
             self.assertEqual(pending, [], f"status={status} should not have pending_node")
             terminal = [x for x in items if x["kind"] == "terminal"]
-            self.assertEqual(len(terminal), 1, f"status={status} should have one terminal")
+            # 打回仅展示审批人节点「已打回」,不追加独立终态行
+            expect_n = 0 if status == "returned" else 1
+            self.assertEqual(
+                len(terminal), expect_n, f"status={status} terminal count"
+            )
 
     def test_progress_returned_has_no_kind_return_node(self):
         from app.services.oa_engine import build_progress_items
@@ -748,8 +752,7 @@ class TestBuildProgress(unittest.TestCase):
         self.assertEqual(tasks[0]["status"], "returned")
         self.assertEqual(tasks[0]["commentText"], "打回")
         terminals = [x for x in items if x["kind"] == "terminal"]
-        self.assertEqual(len(terminals), 1)
-        self.assertEqual(terminals[0]["status"], "returned")
+        self.assertEqual(terminals, [], "打回不追加独立终态「已打回」行")
 
 
 class TestReturnAndResubmit(unittest.TestCase):
@@ -1110,6 +1113,300 @@ class TestListTasksEnrichment(unittest.TestCase):
                 self.db.commit()
 
 
+class TestTransferTask(unittest.TestCase):
+    def setUp(self):
+        from sqlalchemy import create_engine
+        from sqlalchemy.orm import sessionmaker
+        from app.models.models import OaApp, OaAppVersion, OaInstance, OaInstanceEvent, OaTask, SysUser
+
+        self.engine = create_engine("sqlite:///:memory:")
+        OaApp.__table__.create(self.engine)
+        OaAppVersion.__table__.create(self.engine)
+        OaInstance.__table__.create(self.engine)
+        OaTask.__table__.create(self.engine)
+        OaInstanceEvent.__table__.create(self.engine)
+        SysUser.__table__.create(self.engine)
+        Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
+        self.db = Session()
+        self.OaTask = OaTask
+        self.OaInstance = OaInstance
+        self.OaAppVersion = OaAppVersion
+        self.SysUser = SysUser
+        now = datetime.now()
+        self.db.add(
+            OaApp(
+                id="app1",
+                group_id="",
+                app_name="测试应用",
+                icon_url="",
+                description="",
+                initiator_scope="all",
+                initiator_user_ids="[]",
+                admin_scope="all",
+                admin_user_ids="[]",
+                status="published",
+                tenant_no="zkxw",
+                create_dept_id="",
+                create_time=now,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+        self.db.add(
+            OaAppVersion(
+                id="v1",
+                app_id="app1",
+                version_no=1,
+                status="published",
+                is_latest_published="1",
+                form_schema_json="{}",
+                flow_tree_json="{}",
+                advanced_settings_json="{}",
+                tenant_no="zkxw",
+                create_dept_id="",
+                create_time=now,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+        self.db.add(
+            OaInstance(
+                id="i1",
+                app_id="app1",
+                app_version_id="v1",
+                title="t",
+                status="running",
+                initiator_user_id="u0",
+                initiator_dept_id="d1",
+                form_data_json="{}",
+                tenant_no="zkxw",
+                create_dept_id="",
+                create_time=now,
+                update_time=now,
+                del_flag=0,
+                current_round=1,
+            )
+        )
+        for uid, name in (("u1", "李四"), ("u2", "赵六"), ("u3", "钱七")):
+            self.db.add(
+                SysUser(
+                    id=uid,
+                    user_no=uid,
+                    nick_name=name,
+                    email="",
+                    phone_no="",
+                    password="",
+                    status="1",
+                    tenant_no="zkxw",
+                    create_dept_id="",
+                    create_time=now,
+                    update_time=now,
+                    del_flag=0,
+                )
+            )
+        self.db.add(
+            OaTask(
+                id="t1",
+                instance_id="i1",
+                node_key="a1",
+                node_name="审批人",
+                task_type="approval",
+                assignee_user_id="u1",
+                status="pending",
+                resolve_source="role",
+                sign_mode="and",
+                round_no=1,
+                tenant_no="zkxw",
+                order_num=1,
+                create_dept_id="",
+                create_time=now,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+        self.db.add(
+            OaTask(
+                id="t2",
+                instance_id="i1",
+                node_key="a1",
+                node_name="审批人",
+                task_type="approval",
+                assignee_user_id="u3",
+                status="pending",
+                resolve_source="role",
+                sign_mode="and",
+                round_no=1,
+                tenant_no="zkxw",
+                order_num=2,
+                create_dept_id="",
+                create_time=now,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+        self.db.commit()
+
+    def tearDown(self):
+        self.db.close()
+        self.engine.dispose()
+
+    def test_transfer_rejects_self(self):
+        from app.services.oa_engine import OaEngineError, transfer_task
+
+        with self.assertRaises(OaEngineError) as ctx:
+            transfer_task(
+                self.db, "t1", tenant_no="zkxw", user_id="u1", target_user_id="u1"
+            )
+        self.assertIn("自己", str(ctx.exception))
+
+    def test_transfer_rejects_existing_pending(self):
+        from app.services.oa_engine import OaEngineError, transfer_task
+
+        with self.assertRaises(OaEngineError) as ctx:
+            transfer_task(
+                self.db, "t1", tenant_no="zkxw", user_id="u1", target_user_id="u3"
+            )
+        self.assertIn("待办", str(ctx.exception))
+
+    def test_transfer_rejects_non_assignee(self):
+        from app.services.oa_engine import OaEngineError, transfer_task
+
+        with self.assertRaises(OaEngineError) as ctx:
+            transfer_task(
+                self.db, "t1", tenant_no="zkxw", user_id="u2", target_user_id="u2"
+            )
+        self.assertIn("无权", str(ctx.exception))
+
+    def test_transfer_success_and_keeps_co_approver(self):
+        from app.services.oa_engine import list_tasks, transfer_task
+
+        with patch(
+            "app.services.oa_engine.get_instance_detail",
+            return_value={"id": "i1", "status": "running"},
+        ):
+            transfer_task(
+                self.db,
+                "t1",
+                tenant_no="zkxw",
+                user_id="u1",
+                target_user_id="u2",
+                comment="请协助处理",
+            )
+
+        t1 = self.db.query(self.OaTask).filter(self.OaTask.id == "t1").first()
+        t2 = self.db.query(self.OaTask).filter(self.OaTask.id == "t2").first()
+        new_task = (
+            self.db.query(self.OaTask)
+            .filter(
+                self.OaTask.instance_id == "i1",
+                self.OaTask.assignee_user_id == "u2",
+                self.OaTask.status == "pending",
+            )
+            .first()
+        )
+        self.assertEqual(t1.status, "transferred")
+        self.assertEqual(t1.comment_text, "请协助处理")
+        self.assertEqual(t2.status, "pending")
+        self.assertIsNotNone(new_task)
+        self.assertEqual(new_task.resolve_source, "transfer")
+        self.assertEqual(new_task.sign_mode, "and")
+        self.assertEqual(new_task.node_key, "a1")
+
+        done = list_tasks(self.db, tenant_no="zkxw", user_id="u1", kind="done")
+        self.assertEqual(len(done), 1)
+        self.assertEqual(done[0]["status"], "transferred")
+
+    def test_transfer_comment_optional(self):
+        from app.services.oa_engine import transfer_task
+
+        with patch(
+            "app.services.oa_engine.get_instance_detail",
+            return_value={"id": "i1", "status": "running"},
+        ):
+            transfer_task(
+                self.db,
+                "t1",
+                tenant_no="zkxw",
+                user_id="u1",
+                target_user_id="u2",
+            )
+
+        t1 = self.db.query(self.OaTask).filter(self.OaTask.id == "t1").first()
+        self.assertEqual(t1.status, "transferred")
+        self.assertIsNone(t1.comment_text)
+
+    def test_transfer_chain(self):
+        from app.services.oa_engine import transfer_task
+
+        now = datetime.now()
+        self.db.add(
+            self.SysUser(
+                id="u4",
+                user_no="u4",
+                nick_name="孙八",
+                email="",
+                phone_no="",
+                password="",
+                status="1",
+                tenant_no="zkxw",
+                create_dept_id="",
+                create_time=now,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+        self.db.commit()
+
+        with patch(
+            "app.services.oa_engine.get_instance_detail",
+            return_value={"id": "i1", "status": "running"},
+        ):
+            transfer_task(
+                self.db,
+                "t1",
+                tenant_no="zkxw",
+                user_id="u1",
+                target_user_id="u2",
+            )
+
+        u2_task = (
+            self.db.query(self.OaTask)
+            .filter(
+                self.OaTask.instance_id == "i1",
+                self.OaTask.assignee_user_id == "u2",
+                self.OaTask.status == "pending",
+            )
+            .first()
+        )
+        self.assertIsNotNone(u2_task)
+
+        with patch(
+            "app.services.oa_engine.get_instance_detail",
+            return_value={"id": "i1", "status": "running"},
+        ):
+            transfer_task(
+                self.db,
+                u2_task.id,
+                tenant_no="zkxw",
+                user_id="u2",
+                target_user_id="u4",
+            )
+
+        u2_task = self.db.query(self.OaTask).filter(self.OaTask.id == u2_task.id).first()
+        u4_task = (
+            self.db.query(self.OaTask)
+            .filter(
+                self.OaTask.instance_id == "i1",
+                self.OaTask.assignee_user_id == "u4",
+                self.OaTask.status == "pending",
+            )
+            .first()
+        )
+        self.assertEqual(u2_task.status, "transferred")
+        self.assertIsNotNone(u4_task)
+        self.assertEqual(u4_task.resolve_source, "transfer")
+
+
 if __name__ == "__main__":
     unittest.main()