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:
1 parent
e06e8be60f
commit
aaff660d56
2 files changed
+415
-8
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user