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>
This commit is contained in:
zlwuandCursor committed 2026-07-23 15:12:14 +08:00
1 parent e06e8be60f
commit aaff660d56
2 files changed
+415 -8

No files matched your search

+115 -5
View File
@@ -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
View File
@@ -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()