diff --git a/backend/app/services/oa_engine.py b/backend/app/services/oa_engine.py index 030510c6..988e91bd 100644 --- a/backend/app/services/oa_engine.py +++ b/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, diff --git a/backend/tests/test_oa_engine.py b/backend/tests/test_oa_engine.py index facafba8..f477109e 100644 --- a/backend/tests/test_oa_engine.py +++ b/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()