Files
zkxw/backend/tests/test_oa_engine.py
T
zlwuandCursor 26800865cd feat(oa): add return, resubmit, and multi-round approval progress
Align with DingTalk semantics: reject ends the instance, return keeps it for initiator edits, and revoke-and-resubmit creates a linked new instance. Progress timeline projects rounds via oa_instance_event.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 14:53:52 +08:00

685 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OA 引擎单元测试(纯逻辑 + 轻量 mock)"""
import json
import unittest
from datetime import datetime
from unittest.mock import MagicMock, patch
from app.services.oa_engine import (
OaEngineError,
_find_node,
_flow_node_key_order,
_match_condition_group,
_sort_tasks_for_detail,
advance_from,
)
class TestOaEngineHelpers(unittest.TestCase):
def test_find_node(self):
root = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"child": {"id": "e1", "type": "end"},
},
}
self.assertEqual(_find_node(root, "a1")["id"], "a1")
self.assertEqual(_find_node(root, "e1")["id"], "e1")
self.assertIsNone(_find_node(root, "x"))
def test_condition_match(self):
groups = [
{
"name": "gt3",
"when": "{days} > 3",
"child": {"id": "n1", "type": "end"},
},
{"name": "default", "rules": [], "child": {"id": "n2", "type": "end"}},
]
g = _match_condition_group(groups, {"days": 5})
self.assertEqual(g["name"], "gt3")
g2 = _match_condition_group(groups, {"days": 1})
self.assertEqual(g2["name"], "default")
def test_flow_node_key_order_cc_before_approval(self):
root = {
"id": "s",
"type": "start",
"child": {
"id": "c1",
"type": "cc",
"child": {
"id": "a1",
"type": "approval",
"child": {"id": "e1", "type": "end"},
},
},
}
order = _flow_node_key_order(root)
self.assertLess(order["c1"], order["a1"])
def test_sort_tasks_by_flow_when_same_create_time(self):
"""抄送先于审批设计时,即使 create_time / order_num 相同也应按树序展示。"""
root = {
"id": "s",
"type": "start",
"child": {
"id": "c1",
"type": "cc",
"child": {
"id": "a1",
"type": "approval",
"child": {"id": "e1", "type": "end"},
},
},
}
ts = datetime(2026, 7, 15, 14, 5, 7)
approval = MagicMock(node_key="a1", order_num=0, create_time=ts, id="AAAA")
cc = MagicMock(node_key="c1", order_num=0, create_time=ts, id="BBBB")
# 故意把审批放在列表前面,模拟 create_time 同秒时 PK 乱序
sorted_tasks = _sort_tasks_for_detail([approval, cc], flow_root=root)
self.assertEqual([t.node_key for t in sorted_tasks], ["c1", "a1"])
class TestAdvanceFrom(unittest.TestCase):
def test_linear_creates_approval_then_stops(self):
flow = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "负责人",
"approverConfig": {"assigneeType": "dept_leader"},
"child": {"id": "e1", "type": "end"},
},
}
db = MagicMock()
inst = MagicMock(
id="inst1",
tenant_no="zkxw",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
)
ver = MagicMock(
flow_tree_json=json.dumps(flow),
advanced_settings_json=json.dumps({"supervisorFallbackToDeptLeader": True}),
)
fake_task = MagicMock()
with patch(
"app.services.oa_engine._next_task_order_num", return_value=1
), patch(
"app.services.oa_engine._create_tasks_for_node",
return_value=[fake_task],
) as mock_create:
advance_from(db, inst=inst, ver=ver, from_node=None, user_id="u0")
mock_create.assert_called_once()
self.assertEqual(inst.status, "running")
self.assertEqual(mock_create.call_args.kwargs.get("order_num_start"), 1)
def test_complete_path_to_end_via_cc(self):
flow = {
"id": "s",
"type": "start",
"child": {
"id": "c1",
"type": "cc",
"name": "抄送",
"approverConfig": {"assigneeType": "users", "userIds": ["u9"]},
"child": {"id": "e1", "type": "end"},
},
}
db = MagicMock()
inst = MagicMock(
id="inst1",
tenant_no="zkxw",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
update_time=datetime.now(),
)
ver = MagicMock(
flow_tree_json=json.dumps(flow),
advanced_settings_json="{}",
)
with patch(
"app.services.oa_engine._next_task_order_num", return_value=1
), patch(
"app.services.oa_engine._create_tasks_for_node",
return_value=[MagicMock()],
):
advance_from(db, inst=inst, ver=ver, from_node=None, user_id="u0")
self.assertEqual(inst.status, "completed")
def test_empty_assignee_raises(self):
flow = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"approverConfig": {"assigneeType": "dept_leader"},
"child": {"id": "e1", "type": "end"},
},
}
db = MagicMock()
inst = MagicMock(
id="inst1",
tenant_no="zkxw",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
)
ver = MagicMock(flow_tree_json=json.dumps(flow), advanced_settings_json="{}")
with patch(
"app.services.oa_engine._next_task_order_num", return_value=1
), patch("app.services.oa_engine._create_tasks_for_node", return_value=[]):
with self.assertRaises(OaEngineError):
advance_from(db, inst=inst, ver=ver, from_node=None, user_id="u0")
class TestAddSign(unittest.TestCase):
def test_add_sign_rejects_when_disabled(self):
from app.services.oa_engine import add_sign
db = MagicMock()
task = MagicMock(
id="t1",
assignee_user_id="u1",
task_type="approval",
status="pending",
instance_id="i1",
node_key="a1",
node_name="审批",
sign_mode="or",
comment_text=None,
)
inst = MagicMock(id="i1", status="running", initiator_dept_id="d1")
ver = MagicMock(
id="v1",
advanced_settings_json=json.dumps({"allowAddSign": False}),
del_flag=0,
)
db.query.return_value.filter.return_value.first.side_effect = [task, inst, ver]
with self.assertRaises(OaEngineError) as ctx:
add_sign(
db,
"t1",
tenant_no="zkxw",
user_id="u1",
user_ids=["u2"],
)
self.assertIn("不允许加签", str(ctx.exception))
def test_add_sign_requires_users(self):
from app.services.oa_engine import add_sign
db = MagicMock()
task = MagicMock(
id="t1",
assignee_user_id="u1",
task_type="approval",
status="pending",
instance_id="i1",
)
inst = MagicMock(id="i1", status="running")
ver = MagicMock(advanced_settings_json="{}", del_flag=0)
db.query.return_value.filter.return_value.first.side_effect = [task, inst, ver]
with self.assertRaises(OaEngineError) as ctx:
add_sign(db, "t1", tenant_no="zkxw", user_id="u1", user_ids=[])
self.assertIn("加签人", str(ctx.exception))
class TestCompleteTaskOrSign(unittest.TestCase):
"""回归:Session autoflush=False 时,或签取消同节点待办不可覆盖本人已同意状态。"""
def setUp(self):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.models.models import OaAppVersion, OaInstance, OaInstanceEvent, OaTask
self.engine = create_engine("sqlite:///:memory:")
# 仅建引擎相关表
OaAppVersion.__table__.create(self.engine)
OaInstance.__table__.create(self.engine)
OaTask.__table__.create(self.engine)
OaInstanceEvent.__table__.create(self.engine)
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self.db = Session()
self.Base = Base
self.OaAppVersion = OaAppVersion
self.OaInstance = OaInstance
self.OaTask = OaTask
flow = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"approverConfig": {"signMode": "or"},
"child": {"id": "e1", "type": "end"},
},
}
now = datetime.now()
self.db.add(
OaAppVersion(
id="v1",
app_id="app1",
version_no=1,
status="published",
is_latest_published="1",
form_schema_json="{}",
flow_tree_json=json.dumps(flow),
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="单人审批",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
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",
sign_mode="or",
tenant_no="zkxw",
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_approve_keeps_approved_not_done(self):
from app.services.oa_engine import complete_task
with patch(
"app.services.oa_engine.get_instance_detail",
return_value={"id": "i1", "status": "completed"},
):
complete_task(
self.db,
"t1",
tenant_no="zkxw",
user_id="u1",
action="approve",
comment="同意",
)
task = self.db.query(self.OaTask).filter(self.OaTask.id == "t1").first()
self.assertEqual(task.status, "approved")
self.assertEqual(task.comment_text, "同意")
# 已处理列表应能查到
from app.services.oa_engine import list_tasks
done = list_tasks(self.db, tenant_no="zkxw", user_id="u1", kind="done")
self.assertEqual(len(done), 1)
self.assertEqual(done[0]["status"], "approved")
def test_or_sign_cancels_siblings_only(self):
from app.services.oa_engine import complete_task
now = datetime.now()
self.db.add(
self.OaTask(
id="t2",
instance_id="i1",
node_key="a1",
node_name="审批人",
task_type="approval",
assignee_user_id="u2",
status="pending",
sign_mode="or",
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": "completed"},
):
complete_task(
self.db,
"t1",
tenant_no="zkxw",
user_id="u1",
action="approve",
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()
self.assertEqual(t1.status, "approved")
self.assertEqual(t2.status, "done")
class TestBuildProgress(unittest.TestCase):
def test_cc_before_approval_and_pending_ahead(self):
from app.services.oa_engine import build_progress_items
root = {
"id": "s",
"type": "start",
"child": {
"id": "c1",
"type": "cc",
"name": "抄送人",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"child": {
"id": "a2",
"type": "approval",
"name": "总经理",
"child": {"id": "e1", "type": "end"},
},
},
},
}
ts = datetime(2026, 7, 15, 14, 5, 7)
# 审批任务 create 同秒但列表故意审批在前
t_approval = MagicMock(
id="tA",
node_key="a1",
node_name="审批人",
task_type="approval",
status="pending",
assignee_user_id="u2",
order_num=0,
round_no=1,
create_time=ts,
update_time=ts,
comment_text=None,
resolve_source="users",
)
t_cc = MagicMock(
id="tC",
node_key="c1",
node_name="抄送人",
task_type="cc",
status="done",
assignee_user_id="u9",
order_num=0,
round_no=1,
create_time=ts,
update_time=ts,
comment_text=None,
resolve_source="users",
)
inst = MagicMock(
status="running",
initiator_user_id="u0",
current_round=1,
create_time=ts,
update_time=ts,
)
items = build_progress_items(
inst=inst,
flow_root=root,
tasks=[t_approval, t_cc],
name_map={"u0": "发起人甲", "u2": "审批乙", "u9": "抄送丙"},
events=[], # P0 可空
)
kinds = [x["kind"] for x in items]
self.assertEqual(kinds[0], "submit")
# 已产生:cc 再 approval
self.assertEqual(
[x["nodeKey"] for x in items if x["kind"] == "task"],
["c1", "a1"],
)
# running 时未到达 a2
pending = [x for x in items if x["kind"] == "pending_node"]
self.assertTrue(any(p["nodeKey"] == "a2" for p in pending))
def test_terminal_status_excludes_pending_nodes(self):
from app.services.oa_engine import build_progress_items
root = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"child": {
"id": "a2",
"type": "approval",
"name": "总经理",
"child": {"id": "e1", "type": "end"},
},
},
}
ts = datetime(2026, 7, 15, 14, 5, 7)
t_approval = MagicMock(
id="tA",
node_key="a1",
node_name="审批人",
task_type="approval",
status="rejected",
assignee_user_id="u2",
order_num=0,
round_no=1,
create_time=ts,
update_time=ts,
comment_text="不同意",
resolve_source="users",
)
for status in ("rejected", "completed", "cancelled"):
inst = MagicMock(
status=status,
initiator_user_id="u0",
current_round=1,
create_time=ts,
update_time=ts,
)
items = build_progress_items(
inst=inst,
flow_root=root,
tasks=[t_approval],
name_map={"u0": "发起人甲", "u2": "审批乙"},
events=[],
)
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")
class TestReturnAndResubmit(unittest.TestCase):
def setUp(self):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.models import OaAppVersion, OaInstance, OaInstanceEvent, OaTask
self.engine = create_engine("sqlite:///:memory:")
OaAppVersion.__table__.create(self.engine)
OaInstance.__table__.create(self.engine)
OaTask.__table__.create(self.engine)
OaInstanceEvent.__table__.create(self.engine)
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self.db = Session()
self.OaInstance = OaInstance
self.OaTask = OaTask
self.OaInstanceEvent = OaInstanceEvent
flow = {
"id": "s",
"type": "start",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"approverConfig": {
"assigneeType": "users",
"userIds": ["u1"],
"signMode": "or",
},
"child": {"id": "e1", "type": "end"},
},
}
now = datetime.now()
self.db.add(
OaAppVersion(
id="v1",
app_id="app1",
version_no=1,
status="published",
is_latest_published="1",
form_schema_json="{}",
flow_tree_json=json.dumps(flow),
advanced_settings_json=json.dumps({"allowReturn": True}),
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="打回测",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
status="running",
current_round=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",
sign_mode="or",
round_no=1,
tenant_no="zkxw",
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_return_sets_returned_and_event(self):
from app.services.oa_engine import return_task
with patch(
"app.services.oa_engine.get_instance_detail",
return_value={"id": "i1", "status": "returned"},
):
return_task(
self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
)
inst = self.db.query(self.OaInstance).filter_by(id="i1").first()
task = self.db.query(self.OaTask).filter_by(id="t1").first()
ev = (
self.db.query(self.OaInstanceEvent)
.filter_by(instance_id="i1", event_type="return")
.first()
)
self.assertEqual(inst.status, "returned")
self.assertEqual(task.status, "returned")
self.assertIsNotNone(ev)
self.assertEqual(ev.comment_text, "请补充材料")
def test_resubmit_starts_new_round(self):
from app.services.oa_engine import resubmit_instance, return_task
with patch(
"app.services.oa_engine.get_instance_detail",
return_value={"id": "i1", "status": "returned"},
):
return_task(
self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
)
fake_task = MagicMock()
with patch(
"app.services.oa_engine._create_tasks_for_node", return_value=[fake_task]
), patch(
"app.services.oa_engine.get_instance_detail",
return_value={"id": "i1", "status": "running"},
):
resubmit_instance(
self.db,
"i1",
tenant_no="zkxw",
user_id="u0",
form_data={"x": 1},
)
inst = self.db.query(self.OaInstance).filter_by(id="i1").first()
self.assertEqual(inst.status, "running")
self.assertEqual(inst.current_round, 2)
submit_ev = (
self.db.query(self.OaInstanceEvent)
.filter_by(instance_id="i1", event_type="submit", round_no=2)
.first()
)
self.assertIsNotNone(submit_ev)
if __name__ == "__main__":
unittest.main()