test_oa_engine.py 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605
  1. """OA 引擎单元测试(纯逻辑 + 轻量 mock)"""
  2. import json
  3. import unittest
  4. from datetime import datetime
  5. from unittest.mock import MagicMock, patch
  6. from app.services.oa_engine import (
  7. OaEngineError,
  8. _find_node,
  9. _flow_node_key_order,
  10. _match_condition_group,
  11. _sort_tasks_for_detail,
  12. advance_from,
  13. )
  14. class TestOaEngineHelpers(unittest.TestCase):
  15. def test_find_node(self):
  16. root = {
  17. "id": "s",
  18. "type": "start",
  19. "child": {
  20. "id": "a1",
  21. "type": "approval",
  22. "child": {"id": "e1", "type": "end"},
  23. },
  24. }
  25. self.assertEqual(_find_node(root, "a1")["id"], "a1")
  26. self.assertEqual(_find_node(root, "e1")["id"], "e1")
  27. self.assertIsNone(_find_node(root, "x"))
  28. def test_condition_match(self):
  29. groups = [
  30. {
  31. "name": "gt3",
  32. "when": "{days} > 3",
  33. "child": {"id": "n1", "type": "end"},
  34. },
  35. {"name": "default", "rules": [], "child": {"id": "n2", "type": "end"}},
  36. ]
  37. g = _match_condition_group(groups, {"days": 5})
  38. self.assertEqual(g["name"], "gt3")
  39. g2 = _match_condition_group(groups, {"days": 1})
  40. self.assertEqual(g2["name"], "default")
  41. def test_condition_match_rules_list(self):
  42. groups = [
  43. {
  44. "name": "gt3",
  45. "rules": [{"field": "days", "op": ">", "value": 3}],
  46. "child": {"id": "n1", "type": "end"},
  47. },
  48. {"name": "default", "isDefault": True, "rules": [], "child": {"id": "n2", "type": "end"}},
  49. ]
  50. self.assertEqual(_match_condition_group(groups, {"days": 5})["name"], "gt3")
  51. self.assertEqual(_match_condition_group(groups, {"days": 1})["name"], "default")
  52. def test_condition_match_rules_and(self):
  53. groups = [
  54. {
  55. "name": "both",
  56. "rules": [
  57. {"field": "days", "op": ">", "value": 3},
  58. {"field": "leaveType", "op": "==", "value": "事假"},
  59. ],
  60. "child": {"id": "n1", "type": "end"},
  61. },
  62. {"name": "default", "rules": [], "isDefault": True, "child": {"id": "n2", "type": "end"}},
  63. ]
  64. self.assertEqual(
  65. _match_condition_group(groups, {"days": 5, "leaveType": "事假"})["name"],
  66. "both",
  67. )
  68. self.assertEqual(
  69. _match_condition_group(groups, {"days": 5, "leaveType": "病假"})["name"],
  70. "default",
  71. )
  72. def test_condition_contains_person(self):
  73. groups = [
  74. {
  75. "name": "hit",
  76. "rules": [
  77. {"field": "Fperson", "op": "contains", "value": ["u1", "u2"]}
  78. ],
  79. "child": {"id": "n1", "type": "end"},
  80. },
  81. {"name": "default", "rules": [], "child": {"id": "n2", "type": "end"}},
  82. ]
  83. g = _match_condition_group(
  84. groups,
  85. {"Fperson": [{"id": "u2", "name": "李四"}]},
  86. )
  87. self.assertEqual(g["name"], "hit")
  88. g2 = _match_condition_group(
  89. groups,
  90. {"Fperson": [{"id": "u9", "name": "其他人"}]},
  91. )
  92. self.assertEqual(g2["name"], "default")
  93. def test_condition_contains_person_single_object(self):
  94. groups = [
  95. {
  96. "name": "hit",
  97. "rules": [{"field": "Fperson", "op": "contains", "value": ["u1"]}],
  98. "child": {"id": "n1", "type": "end"},
  99. },
  100. {"name": "default", "isDefault": True, "child": {"id": "n2", "type": "end"}},
  101. ]
  102. g = _match_condition_group(
  103. groups, {"Fperson": {"id": "u1", "name": "张三"}}
  104. )
  105. self.assertEqual(g["name"], "hit")
  106. def test_condition_contains_dept(self):
  107. groups = [
  108. {
  109. "name": "hit",
  110. "rules": [
  111. {"field": "Fdept", "op": "contains", "value": ["d1", "d2"]}
  112. ],
  113. "child": {"id": "n1", "type": "end"},
  114. },
  115. {"name": "default", "rules": [], "child": {"id": "n2", "type": "end"}},
  116. ]
  117. g = _match_condition_group(
  118. groups,
  119. {"Fdept": [{"id": "d2", "name": "研发"}]},
  120. )
  121. self.assertEqual(g["name"], "hit")
  122. g2 = _match_condition_group(
  123. groups,
  124. {"Fdept": [{"id": "d9", "name": "其他"}]},
  125. )
  126. self.assertEqual(g2["name"], "default")
  127. def test_condition_contains_owner_dept_single_object(self):
  128. groups = [
  129. {
  130. "name": "hit",
  131. "rules": [{"field": "Fod", "op": "contains", "value": ["d1"]}],
  132. "child": {"id": "n1", "type": "end"},
  133. },
  134. {"name": "default", "isDefault": True, "child": {"id": "n2", "type": "end"}},
  135. ]
  136. g = _match_condition_group(
  137. groups, {"Fod": {"id": "d1", "name": "研发"}}
  138. )
  139. self.assertEqual(g["name"], "hit")
  140. def test_condition_contains_initiator(self):
  141. groups = [
  142. {
  143. "name": "hit",
  144. "rules": [
  145. {
  146. "field": "__initiator__",
  147. "op": "contains",
  148. "value": ["u1", "u2"],
  149. }
  150. ],
  151. "child": {"id": "n1", "type": "end"},
  152. },
  153. {"name": "default", "rules": [], "child": {"id": "n2", "type": "end"}},
  154. ]
  155. g = _match_condition_group(groups, {}, initiator_user_id="u2")
  156. self.assertEqual(g["name"], "hit")
  157. g2 = _match_condition_group(groups, {}, initiator_user_id="u9")
  158. self.assertEqual(g2["name"], "default")
  159. def test_condition_initiator_and_form_field(self):
  160. groups = [
  161. {
  162. "name": "both",
  163. "rules": [
  164. {"field": "__initiator__", "op": "contains", "value": ["u1"]},
  165. {"field": "days", "op": ">", "value": 3},
  166. ],
  167. "child": {"id": "n1", "type": "end"},
  168. },
  169. {"name": "default", "isDefault": True, "child": {"id": "n2", "type": "end"}},
  170. ]
  171. self.assertEqual(
  172. _match_condition_group(
  173. groups, {"days": 5}, initiator_user_id="u1"
  174. )["name"],
  175. "both",
  176. )
  177. self.assertEqual(
  178. _match_condition_group(
  179. groups, {"days": 5}, initiator_user_id="u9"
  180. )["name"],
  181. "default",
  182. )
  183. def test_flow_node_key_order_cc_before_approval(self):
  184. root = {
  185. "id": "s",
  186. "type": "start",
  187. "child": {
  188. "id": "c1",
  189. "type": "cc",
  190. "child": {
  191. "id": "a1",
  192. "type": "approval",
  193. "child": {"id": "e1", "type": "end"},
  194. },
  195. },
  196. }
  197. order = _flow_node_key_order(root)
  198. self.assertLess(order["c1"], order["a1"])
  199. def test_sort_tasks_by_flow_when_same_create_time(self):
  200. """抄送先于审批设计时,即使 create_time / order_num 相同也应按树序展示。"""
  201. root = {
  202. "id": "s",
  203. "type": "start",
  204. "child": {
  205. "id": "c1",
  206. "type": "cc",
  207. "child": {
  208. "id": "a1",
  209. "type": "approval",
  210. "child": {"id": "e1", "type": "end"},
  211. },
  212. },
  213. }
  214. ts = datetime(2026, 7, 15, 14, 5, 7)
  215. approval = MagicMock(node_key="a1", order_num=0, create_time=ts, id="AAAA")
  216. cc = MagicMock(node_key="c1", order_num=0, create_time=ts, id="BBBB")
  217. # 故意把审批放在列表前面,模拟 create_time 同秒时 PK 乱序
  218. sorted_tasks = _sort_tasks_for_detail([approval, cc], flow_root=root)
  219. self.assertEqual([t.node_key for t in sorted_tasks], ["c1", "a1"])
  220. class TestAdvanceFrom(unittest.TestCase):
  221. def test_linear_creates_approval_then_stops(self):
  222. flow = {
  223. "id": "s",
  224. "type": "start",
  225. "child": {
  226. "id": "a1",
  227. "type": "approval",
  228. "name": "负责人",
  229. "approverConfig": {"assigneeType": "dept_leader"},
  230. "child": {"id": "e1", "type": "end"},
  231. },
  232. }
  233. db = MagicMock()
  234. inst = MagicMock(
  235. id="inst1",
  236. tenant_no="zkxw",
  237. initiator_user_id="u0",
  238. initiator_dept_id="d1",
  239. form_data_json="{}",
  240. status="running",
  241. )
  242. ver = MagicMock(
  243. flow_tree_json=json.dumps(flow),
  244. advanced_settings_json=json.dumps({"supervisorFallbackToDeptLeader": True}),
  245. )
  246. fake_task = MagicMock()
  247. with patch(
  248. "app.services.oa_engine._next_task_order_num", return_value=1
  249. ), patch(
  250. "app.services.oa_engine._create_tasks_for_node",
  251. return_value=[fake_task],
  252. ) as mock_create:
  253. advance_from(db, inst=inst, ver=ver, from_node=None, user_id="u0")
  254. mock_create.assert_called_once()
  255. self.assertEqual(inst.status, "running")
  256. self.assertEqual(mock_create.call_args.kwargs.get("order_num_start"), 1)
  257. def test_complete_path_to_end_via_cc(self):
  258. flow = {
  259. "id": "s",
  260. "type": "start",
  261. "child": {
  262. "id": "c1",
  263. "type": "cc",
  264. "name": "抄送",
  265. "approverConfig": {"assigneeType": "users", "userIds": ["u9"]},
  266. "child": {"id": "e1", "type": "end"},
  267. },
  268. }
  269. db = MagicMock()
  270. inst = MagicMock(
  271. id="inst1",
  272. tenant_no="zkxw",
  273. initiator_user_id="u0",
  274. initiator_dept_id="d1",
  275. form_data_json="{}",
  276. status="running",
  277. update_time=datetime.now(),
  278. )
  279. ver = MagicMock(
  280. flow_tree_json=json.dumps(flow),
  281. advanced_settings_json="{}",
  282. )
  283. with patch(
  284. "app.services.oa_engine._next_task_order_num", return_value=1
  285. ), patch(
  286. "app.services.oa_engine._create_tasks_for_node",
  287. return_value=[MagicMock()],
  288. ):
  289. advance_from(db, inst=inst, ver=ver, from_node=None, user_id="u0")
  290. self.assertEqual(inst.status, "completed")
  291. def test_empty_assignee_raises(self):
  292. flow = {
  293. "id": "s",
  294. "type": "start",
  295. "child": {
  296. "id": "a1",
  297. "type": "approval",
  298. "approverConfig": {"assigneeType": "dept_leader"},
  299. "child": {"id": "e1", "type": "end"},
  300. },
  301. }
  302. db = MagicMock()
  303. inst = MagicMock(
  304. id="inst1",
  305. tenant_no="zkxw",
  306. initiator_user_id="u0",
  307. initiator_dept_id="d1",
  308. form_data_json="{}",
  309. status="running",
  310. )
  311. ver = MagicMock(flow_tree_json=json.dumps(flow), advanced_settings_json="{}")
  312. with patch(
  313. "app.services.oa_engine._next_task_order_num", return_value=1
  314. ), patch("app.services.oa_engine._create_tasks_for_node", return_value=[]):
  315. with self.assertRaises(OaEngineError):
  316. advance_from(db, inst=inst, ver=ver, from_node=None, user_id="u0")
  317. class TestAddSign(unittest.TestCase):
  318. def test_add_sign_rejects_when_disabled(self):
  319. from app.services.oa_engine import add_sign
  320. db = MagicMock()
  321. task = MagicMock(
  322. id="t1",
  323. assignee_user_id="u1",
  324. task_type="approval",
  325. status="pending",
  326. instance_id="i1",
  327. node_key="a1",
  328. node_name="审批",
  329. sign_mode="or",
  330. comment_text=None,
  331. )
  332. inst = MagicMock(id="i1", status="running", initiator_dept_id="d1")
  333. ver = MagicMock(
  334. id="v1",
  335. advanced_settings_json=json.dumps({"allowAddSign": False}),
  336. del_flag=0,
  337. )
  338. db.query.return_value.filter.return_value.first.side_effect = [task, inst, ver]
  339. with self.assertRaises(OaEngineError) as ctx:
  340. add_sign(
  341. db,
  342. "t1",
  343. tenant_no="zkxw",
  344. user_id="u1",
  345. user_ids=["u2"],
  346. )
  347. self.assertIn("不允许加签", str(ctx.exception))
  348. def test_add_sign_requires_users(self):
  349. from app.services.oa_engine import add_sign
  350. db = MagicMock()
  351. task = MagicMock(
  352. id="t1",
  353. assignee_user_id="u1",
  354. task_type="approval",
  355. status="pending",
  356. instance_id="i1",
  357. )
  358. inst = MagicMock(id="i1", status="running")
  359. ver = MagicMock(advanced_settings_json="{}", del_flag=0)
  360. db.query.return_value.filter.return_value.first.side_effect = [task, inst, ver]
  361. with self.assertRaises(OaEngineError) as ctx:
  362. add_sign(db, "t1", tenant_no="zkxw", user_id="u1", user_ids=[])
  363. self.assertIn("加签人", str(ctx.exception))
  364. class TestCompleteTaskOrSign(unittest.TestCase):
  365. """回归:Session autoflush=False 时,或签取消同节点待办不可覆盖本人已同意状态。"""
  366. def setUp(self):
  367. from sqlalchemy import create_engine
  368. from sqlalchemy.orm import sessionmaker
  369. from app.core.database import Base
  370. from app.models.models import OaApp, OaAppVersion, OaInstance, OaInstanceEvent, OaTask, SysUser
  371. self.engine = create_engine("sqlite:///:memory:")
  372. # 仅建引擎相关表
  373. OaApp.__table__.create(self.engine)
  374. OaAppVersion.__table__.create(self.engine)
  375. OaInstance.__table__.create(self.engine)
  376. OaTask.__table__.create(self.engine)
  377. OaInstanceEvent.__table__.create(self.engine)
  378. SysUser.__table__.create(self.engine)
  379. Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
  380. self.db = Session()
  381. self.Base = Base
  382. self.OaAppVersion = OaAppVersion
  383. self.OaInstance = OaInstance
  384. self.OaTask = OaTask
  385. flow = {
  386. "id": "s",
  387. "type": "start",
  388. "child": {
  389. "id": "a1",
  390. "type": "approval",
  391. "name": "审批人",
  392. "approverConfig": {"signMode": "or"},
  393. "child": {"id": "e1", "type": "end"},
  394. },
  395. }
  396. now = datetime.now()
  397. self.db.add(
  398. OaAppVersion(
  399. id="v1",
  400. app_id="app1",
  401. version_no=1,
  402. status="published",
  403. is_latest_published="1",
  404. form_schema_json="{}",
  405. flow_tree_json=json.dumps(flow),
  406. advanced_settings_json="{}",
  407. tenant_no="zkxw",
  408. create_dept_id="",
  409. create_time=now,
  410. update_time=now,
  411. del_flag=0,
  412. )
  413. )
  414. self.db.add(
  415. OaInstance(
  416. id="i1",
  417. app_id="app1",
  418. app_version_id="v1",
  419. title="单人审批",
  420. initiator_user_id="u0",
  421. initiator_dept_id="d1",
  422. form_data_json="{}",
  423. status="running",
  424. tenant_no="zkxw",
  425. create_dept_id="",
  426. create_time=now,
  427. update_time=now,
  428. del_flag=0,
  429. )
  430. )
  431. self.db.add(
  432. OaTask(
  433. id="t1",
  434. instance_id="i1",
  435. node_key="a1",
  436. node_name="审批人",
  437. task_type="approval",
  438. assignee_user_id="u1",
  439. status="pending",
  440. sign_mode="or",
  441. tenant_no="zkxw",
  442. create_dept_id="",
  443. create_time=now,
  444. update_time=now,
  445. del_flag=0,
  446. )
  447. )
  448. self.db.commit()
  449. def tearDown(self):
  450. self.db.close()
  451. self.engine.dispose()
  452. def test_approve_keeps_approved_not_done(self):
  453. from app.services.oa_engine import complete_task
  454. with patch(
  455. "app.services.oa_engine.get_instance_detail",
  456. return_value={"id": "i1", "status": "completed"},
  457. ):
  458. complete_task(
  459. self.db,
  460. "t1",
  461. tenant_no="zkxw",
  462. user_id="u1",
  463. action="approve",
  464. comment="同意",
  465. )
  466. task = self.db.query(self.OaTask).filter(self.OaTask.id == "t1").first()
  467. self.assertEqual(task.status, "approved")
  468. self.assertEqual(task.comment_text, "同意")
  469. # 已处理列表应能查到
  470. from app.services.oa_engine import list_tasks
  471. done = list_tasks(self.db, tenant_no="zkxw", user_id="u1", kind="done")["list"]
  472. self.assertEqual(len(done), 1)
  473. self.assertEqual(done[0]["status"], "approved")
  474. def test_or_sign_cancels_siblings_only(self):
  475. from app.services.oa_engine import complete_task
  476. now = datetime.now()
  477. self.db.add(
  478. self.OaTask(
  479. id="t2",
  480. instance_id="i1",
  481. node_key="a1",
  482. node_name="审批人",
  483. task_type="approval",
  484. assignee_user_id="u2",
  485. status="pending",
  486. sign_mode="or",
  487. tenant_no="zkxw",
  488. create_dept_id="",
  489. create_time=now,
  490. update_time=now,
  491. del_flag=0,
  492. )
  493. )
  494. self.db.commit()
  495. with patch(
  496. "app.services.oa_engine.get_instance_detail",
  497. return_value={"id": "i1", "status": "completed"},
  498. ):
  499. complete_task(
  500. self.db,
  501. "t1",
  502. tenant_no="zkxw",
  503. user_id="u1",
  504. action="approve",
  505. comment="同意",
  506. )
  507. t1 = self.db.query(self.OaTask).filter(self.OaTask.id == "t1").first()
  508. t2 = self.db.query(self.OaTask).filter(self.OaTask.id == "t2").first()
  509. self.assertEqual(t1.status, "approved")
  510. self.assertEqual(t2.status, "done")
  511. class TestBuildProgress(unittest.TestCase):
  512. def test_cc_before_approval_and_pending_ahead(self):
  513. from app.services.oa_engine import build_progress_items
  514. root = {
  515. "id": "s",
  516. "type": "start",
  517. "child": {
  518. "id": "c1",
  519. "type": "cc",
  520. "name": "抄送人",
  521. "child": {
  522. "id": "a1",
  523. "type": "approval",
  524. "name": "审批人",
  525. "child": {
  526. "id": "a2",
  527. "type": "approval",
  528. "name": "总经理",
  529. "child": {"id": "e1", "type": "end"},
  530. },
  531. },
  532. },
  533. }
  534. ts = datetime(2026, 7, 15, 14, 5, 7)
  535. # 审批任务 create 同秒但列表故意审批在前
  536. t_approval = MagicMock(
  537. id="tA",
  538. node_key="a1",
  539. node_name="审批人",
  540. task_type="approval",
  541. status="pending",
  542. assignee_user_id="u2",
  543. order_num=0,
  544. round_no=1,
  545. create_time=ts,
  546. update_time=ts,
  547. comment_text=None,
  548. resolve_source="users",
  549. )
  550. t_cc = MagicMock(
  551. id="tC",
  552. node_key="c1",
  553. node_name="抄送人",
  554. task_type="cc",
  555. status="done",
  556. assignee_user_id="u9",
  557. order_num=0,
  558. round_no=1,
  559. create_time=ts,
  560. update_time=ts,
  561. comment_text=None,
  562. resolve_source="users",
  563. )
  564. inst = MagicMock(
  565. status="running",
  566. initiator_user_id="u0",
  567. current_round=1,
  568. create_time=ts,
  569. update_time=ts,
  570. )
  571. items = build_progress_items(
  572. inst=inst,
  573. flow_root=root,
  574. tasks=[t_approval, t_cc],
  575. name_map={"u0": "发起人甲", "u2": "审批乙", "u9": "抄送丙"},
  576. events=[], # P0 可空
  577. )
  578. kinds = [x["kind"] for x in items]
  579. self.assertEqual(kinds[0], "submit")
  580. # 已产生:cc 再 approval
  581. self.assertEqual(
  582. [x["nodeKey"] for x in items if x["kind"] == "task"],
  583. ["c1", "a1"],
  584. )
  585. # running 时未到达 a2
  586. pending = [x for x in items if x["kind"] == "pending_node"]
  587. self.assertTrue(any(p["nodeKey"] == "a2" for p in pending))
  588. def test_running_shows_all_subsequent_pending_nodes(self):
  589. """进行中应挂出路径上全部后续节点,而非只露一个空壳。"""
  590. from app.services.oa_engine import build_progress_items
  591. root = {
  592. "id": "s",
  593. "type": "start",
  594. "child": {
  595. "id": "a1",
  596. "type": "approval",
  597. "name": "一级审批",
  598. "child": {
  599. "id": "a2",
  600. "type": "approval",
  601. "name": "二级审批",
  602. "child": {
  603. "id": "a3",
  604. "type": "approval",
  605. "name": "三级审批",
  606. "child": {"id": "e1", "type": "end"},
  607. },
  608. },
  609. },
  610. }
  611. ts = datetime(2026, 7, 17, 11, 0, 0)
  612. t1 = MagicMock(
  613. id="t1",
  614. node_key="a1",
  615. node_name="一级审批",
  616. task_type="approval",
  617. status="pending",
  618. assignee_user_id="u1",
  619. order_num=0,
  620. round_no=1,
  621. create_time=ts,
  622. update_time=ts,
  623. comment_text=None,
  624. resolve_source="users",
  625. sign_mode="or",
  626. )
  627. inst = MagicMock(
  628. status="running",
  629. initiator_user_id="u0",
  630. current_round=1,
  631. create_time=ts,
  632. update_time=ts,
  633. form_data_json="{}",
  634. tenant_no="t1",
  635. initiator_dept_id="",
  636. )
  637. items = build_progress_items(
  638. inst=inst,
  639. flow_root=root,
  640. tasks=[t1],
  641. name_map={"u0": "发起人", "u1": "甲"},
  642. events=[],
  643. )
  644. pending = [x for x in items if x["kind"] == "pending_node"]
  645. self.assertEqual([p["nodeKey"] for p in pending], ["a2", "a3", "e1"])
  646. self.assertEqual([p["title"] for p in pending], ["二级审批", "三级审批", "结束"])
  647. self.assertEqual(pending[-1]["taskType"], "end")
  648. def test_or_sign_same_node_merged_into_one_card(self):
  649. """或签同节点三人只投影一张进度卡片,people 含各人状态。"""
  650. from app.services.oa_engine import build_progress_items
  651. root = {
  652. "id": "s",
  653. "type": "start",
  654. "child": {
  655. "id": "a1",
  656. "type": "approval",
  657. "name": "部门审批",
  658. "child": {"id": "e1", "type": "end"},
  659. },
  660. }
  661. ts = datetime(2026, 7, 17, 10, 0, 0)
  662. ts2 = datetime(2026, 7, 17, 10, 5, 0)
  663. def mk(tid, uid, status, comment=None, update=None):
  664. return MagicMock(
  665. id=tid,
  666. node_key="a1",
  667. node_name="部门审批",
  668. task_type="approval",
  669. status=status,
  670. assignee_user_id=uid,
  671. order_num=0,
  672. round_no=1,
  673. create_time=ts,
  674. update_time=update or ts,
  675. comment_text=comment,
  676. resolve_source="users",
  677. sign_mode="or",
  678. )
  679. tasks = [
  680. mk("t1", "u1", "approved", "同意", ts2),
  681. mk("t2", "u2", "done"),
  682. mk("t3", "u3", "done"),
  683. ]
  684. inst = MagicMock(
  685. status="completed",
  686. initiator_user_id="u0",
  687. current_round=1,
  688. create_time=ts,
  689. update_time=ts2,
  690. )
  691. items = build_progress_items(
  692. inst=inst,
  693. flow_root=root,
  694. tasks=tasks,
  695. name_map={"u0": "发起人", "u1": "甲", "u2": "乙", "u3": "丙"},
  696. events=[],
  697. )
  698. task_items = [x for x in items if x["kind"] == "task"]
  699. self.assertEqual(len(task_items), 1)
  700. card = task_items[0]
  701. self.assertEqual(card["nodeKey"], "a1")
  702. self.assertEqual(card["status"], "approved")
  703. self.assertEqual(card["signMode"], "or")
  704. self.assertEqual(len(card["people"]), 3)
  705. self.assertEqual(
  706. [(p["nickName"], p["status"]) for p in card["people"]],
  707. [("甲", "approved"), ("乙", "done"), ("丙", "done")],
  708. )
  709. self.assertIn("甲:同意", card["commentText"])
  710. def test_terminal_status_excludes_pending_nodes(self):
  711. from app.services.oa_engine import build_progress_items
  712. root = {
  713. "id": "s",
  714. "type": "start",
  715. "child": {
  716. "id": "a1",
  717. "type": "approval",
  718. "name": "审批人",
  719. "child": {
  720. "id": "a2",
  721. "type": "approval",
  722. "name": "总经理",
  723. "child": {"id": "e1", "type": "end"},
  724. },
  725. },
  726. }
  727. ts = datetime(2026, 7, 15, 14, 5, 7)
  728. t_approval = MagicMock(
  729. id="tA",
  730. node_key="a1",
  731. node_name="审批人",
  732. task_type="approval",
  733. status="rejected",
  734. assignee_user_id="u2",
  735. order_num=0,
  736. round_no=1,
  737. create_time=ts,
  738. update_time=ts,
  739. comment_text="不同意",
  740. resolve_source="users",
  741. )
  742. for status in ("rejected", "completed", "cancelled", "returned"):
  743. inst = MagicMock(
  744. status=status,
  745. initiator_user_id="u0",
  746. current_round=1,
  747. create_time=ts,
  748. update_time=ts,
  749. )
  750. items = build_progress_items(
  751. inst=inst,
  752. flow_root=root,
  753. tasks=[t_approval],
  754. name_map={"u0": "发起人甲", "u2": "审批乙"},
  755. events=[],
  756. )
  757. pending = [x for x in items if x["kind"] == "pending_node"]
  758. self.assertEqual(pending, [], f"status={status} should not have pending_node")
  759. terminal = [x for x in items if x["kind"] == "terminal"]
  760. # 打回仅展示审批人节点「已打回」,不追加独立终态行
  761. expect_n = 0 if status == "returned" else 1
  762. self.assertEqual(
  763. len(terminal), expect_n, f"status={status} terminal count"
  764. )
  765. def test_cancelled_terminal_attributes_to_initiator(self):
  766. """撤销终态归因发起人;任务 cancelled 仍投影为节点,由前端映射「无需处理」。"""
  767. from app.services.oa_engine import build_progress_items
  768. root = {
  769. "id": "s",
  770. "type": "start",
  771. "child": {
  772. "id": "a1",
  773. "type": "approval",
  774. "name": "直接主管",
  775. "child": {"id": "e1", "type": "end"},
  776. },
  777. }
  778. ts = datetime(2026, 8, 13, 12, 47, 46)
  779. t_pending_cancelled = MagicMock(
  780. id="tA",
  781. node_key="a1",
  782. node_name="直接主管",
  783. task_type="approval",
  784. status="cancelled",
  785. assignee_user_id="u2",
  786. order_num=0,
  787. round_no=1,
  788. create_time=datetime(2026, 8, 13, 11, 17, 38),
  789. update_time=ts,
  790. comment_text=None,
  791. resolve_source="direct_supervisor",
  792. sign_mode="and",
  793. )
  794. inst = MagicMock(
  795. status="cancelled",
  796. initiator_user_id="u0",
  797. current_round=1,
  798. create_time=datetime(2026, 8, 13, 11, 17, 38),
  799. update_time=ts,
  800. )
  801. items = build_progress_items(
  802. inst=inst,
  803. flow_root=root,
  804. tasks=[t_pending_cancelled],
  805. name_map={"u0": "吴子龙", "u2": "张主管"},
  806. events=[],
  807. )
  808. task_card = next(x for x in items if x["kind"] == "task")
  809. self.assertEqual(task_card["title"], "直接主管")
  810. self.assertEqual(task_card["status"], "cancelled")
  811. self.assertEqual(task_card["people"][0]["status"], "cancelled")
  812. self.assertEqual(task_card["timestamp"], "", "cancelled 节点勿带撤销时间")
  813. terminal = next(x for x in items if x["kind"] == "terminal")
  814. self.assertEqual(terminal["status"], "cancelled")
  815. self.assertEqual(terminal["title"], "已撤销")
  816. self.assertEqual(terminal["assigneeUserId"], "u0")
  817. self.assertEqual(terminal["assigneeUserName"], "吴子龙")
  818. self.assertEqual(terminal["commentText"], "发起人撤销")
  819. self.assertTrue(terminal["timestamp"])
  820. def test_progress_returned_has_no_kind_return_node(self):
  821. from app.services.oa_engine import build_progress_items
  822. root = {
  823. "id": "s",
  824. "type": "start",
  825. "child": {
  826. "id": "a1",
  827. "type": "approval",
  828. "name": "审批人",
  829. "child": {"id": "e1", "type": "end"},
  830. },
  831. }
  832. ts = datetime(2026, 7, 15, 14, 58, 8)
  833. t_approval = MagicMock(
  834. id="tA",
  835. node_key="a1",
  836. node_name="审批人",
  837. task_type="approval",
  838. status="returned",
  839. assignee_user_id="u2",
  840. order_num=1,
  841. round_no=1,
  842. create_time=ts,
  843. update_time=ts,
  844. comment_text="打回",
  845. resolve_source="users",
  846. )
  847. ret_ev = MagicMock(
  848. event_type="return",
  849. round_no=1,
  850. actor_user_id="u2",
  851. create_time=ts,
  852. comment_text="打回",
  853. node_key="a1",
  854. task_id="tA",
  855. )
  856. inst = MagicMock(
  857. status="returned",
  858. initiator_user_id="u0",
  859. current_round=1,
  860. create_time=ts,
  861. update_time=ts,
  862. )
  863. items = build_progress_items(
  864. inst=inst,
  865. flow_root=root,
  866. tasks=[t_approval],
  867. name_map={"u0": "发起人", "u2": "审批人"},
  868. events=[ret_ev],
  869. )
  870. self.assertFalse(any(x["kind"] == "return" for x in items))
  871. tasks = [x for x in items if x["kind"] == "task"]
  872. self.assertEqual(len(tasks), 1)
  873. self.assertEqual(tasks[0]["status"], "returned")
  874. self.assertEqual(tasks[0]["commentText"], "打回")
  875. terminals = [x for x in items if x["kind"] == "terminal"]
  876. self.assertEqual(terminals, [], "打回不追加独立终态「已打回」行")
  877. class TestReturnAndResubmit(unittest.TestCase):
  878. def setUp(self):
  879. from sqlalchemy import create_engine
  880. from sqlalchemy.orm import sessionmaker
  881. from app.models.models import OaApp, OaAppVersion, OaInstance, OaInstanceEvent, OaTask, SysUser
  882. self.engine = create_engine("sqlite:///:memory:")
  883. OaApp.__table__.create(self.engine)
  884. OaAppVersion.__table__.create(self.engine)
  885. OaInstance.__table__.create(self.engine)
  886. OaTask.__table__.create(self.engine)
  887. OaInstanceEvent.__table__.create(self.engine)
  888. SysUser.__table__.create(self.engine)
  889. Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
  890. self.db = Session()
  891. self.OaInstance = OaInstance
  892. self.OaTask = OaTask
  893. self.OaInstanceEvent = OaInstanceEvent
  894. flow = {
  895. "id": "s",
  896. "type": "start",
  897. "child": {
  898. "id": "a1",
  899. "type": "approval",
  900. "name": "审批人",
  901. "approverConfig": {
  902. "assigneeType": "users",
  903. "userIds": ["u1"],
  904. "signMode": "or",
  905. },
  906. "child": {"id": "e1", "type": "end"},
  907. },
  908. }
  909. now = datetime.now()
  910. self.db.add(
  911. OaAppVersion(
  912. id="v1",
  913. app_id="app1",
  914. version_no=1,
  915. status="published",
  916. is_latest_published="1",
  917. form_schema_json="{}",
  918. flow_tree_json=json.dumps(flow),
  919. advanced_settings_json=json.dumps({"allowReturn": True}),
  920. tenant_no="zkxw",
  921. create_dept_id="",
  922. create_time=now,
  923. update_time=now,
  924. del_flag=0,
  925. )
  926. )
  927. self.db.add(
  928. OaInstance(
  929. id="i1",
  930. app_id="app1",
  931. app_version_id="v1",
  932. title="打回测",
  933. initiator_user_id="u0",
  934. initiator_dept_id="d1",
  935. form_data_json="{}",
  936. status="running",
  937. current_round=1,
  938. tenant_no="zkxw",
  939. create_dept_id="",
  940. create_time=now,
  941. update_time=now,
  942. del_flag=0,
  943. )
  944. )
  945. self.db.add(
  946. OaTask(
  947. id="t1",
  948. instance_id="i1",
  949. node_key="a1",
  950. node_name="审批人",
  951. task_type="approval",
  952. assignee_user_id="u1",
  953. status="pending",
  954. sign_mode="or",
  955. round_no=1,
  956. tenant_no="zkxw",
  957. create_dept_id="",
  958. create_time=now,
  959. update_time=now,
  960. del_flag=0,
  961. )
  962. )
  963. self.db.commit()
  964. def tearDown(self):
  965. self.db.close()
  966. self.engine.dispose()
  967. def test_return_sets_returned_and_event(self):
  968. from app.services.oa_engine import return_task
  969. with patch(
  970. "app.services.oa_engine.get_instance_detail",
  971. return_value={"id": "i1", "status": "returned"},
  972. ):
  973. return_task(
  974. self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
  975. )
  976. inst = self.db.query(self.OaInstance).filter_by(id="i1").first()
  977. task = self.db.query(self.OaTask).filter_by(id="t1").first()
  978. ev = (
  979. self.db.query(self.OaInstanceEvent)
  980. .filter_by(instance_id="i1", event_type="return")
  981. .first()
  982. )
  983. self.assertEqual(inst.status, "returned")
  984. self.assertEqual(task.status, "returned")
  985. self.assertIsNotNone(ev)
  986. self.assertEqual(ev.comment_text, "请补充材料")
  987. def test_resubmit_raises_deprecated(self):
  988. from app.services.oa_engine import OaEngineError, resubmit_instance, return_task
  989. with patch(
  990. "app.services.oa_engine.get_instance_detail",
  991. return_value={"id": "i1", "status": "returned"},
  992. ):
  993. return_task(
  994. self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
  995. )
  996. with self.assertRaises(OaEngineError) as ctx:
  997. resubmit_instance(
  998. self.db,
  999. "i1",
  1000. tenant_no="zkxw",
  1001. user_id="u0",
  1002. form_data={"x": 1},
  1003. )
  1004. self.assertIn("废弃", str(ctx.exception))
  1005. def test_done_list_includes_returned(self):
  1006. from app.services.oa_engine import list_tasks, return_task
  1007. with patch(
  1008. "app.services.oa_engine.get_instance_detail",
  1009. return_value={"id": "i1", "status": "returned"},
  1010. ):
  1011. return_task(
  1012. self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料"
  1013. )
  1014. done = list_tasks(self.db, tenant_no="zkxw", user_id="u1", kind="done")["list"]
  1015. self.assertEqual(len(done), 1)
  1016. self.assertEqual(done[0]["status"], "returned")
  1017. class TestListTasksEnrichment(unittest.TestCase):
  1018. """list_tasks 返回整单此刻进度摘要字段。"""
  1019. def setUp(self):
  1020. from sqlalchemy import create_engine
  1021. from sqlalchemy.orm import sessionmaker
  1022. from app.models.models import OaApp, OaAppVersion, OaInstance, OaTask, SysUser
  1023. self.engine = create_engine("sqlite:///:memory:")
  1024. OaApp.__table__.create(self.engine)
  1025. OaAppVersion.__table__.create(self.engine)
  1026. OaInstance.__table__.create(self.engine)
  1027. OaTask.__table__.create(self.engine)
  1028. SysUser.__table__.create(self.engine)
  1029. Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
  1030. self.db = Session()
  1031. self.OaTask = OaTask
  1032. now = datetime(2026, 7, 15, 10, 0, 0)
  1033. self.db.add(
  1034. OaApp(
  1035. id="app1",
  1036. app_name="请假审批",
  1037. initiator_scope="all",
  1038. admin_scope="all_oa_admin",
  1039. status="1",
  1040. tenant_no="zkxw",
  1041. create_dept_id="",
  1042. create_time=now,
  1043. update_time=now,
  1044. del_flag=0,
  1045. )
  1046. )
  1047. self.db.add(
  1048. OaAppVersion(
  1049. id="v1",
  1050. app_id="app1",
  1051. version_no=1,
  1052. status="published",
  1053. is_latest_published="1",
  1054. form_schema_json="{}",
  1055. flow_tree_json="{}",
  1056. advanced_settings_json="{}",
  1057. tenant_no="zkxw",
  1058. create_dept_id="",
  1059. create_time=now,
  1060. update_time=now,
  1061. del_flag=0,
  1062. )
  1063. )
  1064. self.db.add(
  1065. SysUser(
  1066. id="u0",
  1067. user_no="initiator",
  1068. nick_name="发起人甲",
  1069. password="x",
  1070. tenant_no="zkxw",
  1071. create_dept_id="",
  1072. create_time=now,
  1073. update_time=now,
  1074. del_flag=0,
  1075. )
  1076. )
  1077. self.db.add(
  1078. SysUser(
  1079. id="u1",
  1080. user_no="approver1",
  1081. nick_name="审批人乙",
  1082. password="x",
  1083. tenant_no="zkxw",
  1084. create_dept_id="",
  1085. create_time=now,
  1086. update_time=now,
  1087. del_flag=0,
  1088. )
  1089. )
  1090. self.db.add(
  1091. SysUser(
  1092. id="u2",
  1093. user_no="approver2",
  1094. nick_name="审批人丙",
  1095. password="x",
  1096. tenant_no="zkxw",
  1097. create_dept_id="",
  1098. create_time=now,
  1099. update_time=now,
  1100. del_flag=0,
  1101. )
  1102. )
  1103. self.db.add(
  1104. OaInstance(
  1105. id="i1",
  1106. app_id="app1",
  1107. app_version_id="v1",
  1108. title="请假-忽略",
  1109. initiator_user_id="u0",
  1110. initiator_dept_id="d1",
  1111. form_data_json="{}",
  1112. status="running",
  1113. tenant_no="zkxw",
  1114. create_dept_id="",
  1115. create_time=now,
  1116. update_time=now,
  1117. del_flag=0,
  1118. )
  1119. )
  1120. self.db.add(
  1121. OaTask(
  1122. id="t-done",
  1123. instance_id="i1",
  1124. node_key="a1",
  1125. node_name="一级审批",
  1126. task_type="approval",
  1127. assignee_user_id="u1",
  1128. status="approved",
  1129. tenant_no="zkxw",
  1130. create_dept_id="",
  1131. create_time=now,
  1132. update_time=now,
  1133. del_flag=0,
  1134. )
  1135. )
  1136. self.db.add(
  1137. OaTask(
  1138. id="t-pending",
  1139. instance_id="i1",
  1140. node_key="a2",
  1141. node_name="二级审批",
  1142. task_type="approval",
  1143. assignee_user_id="u2",
  1144. status="pending",
  1145. tenant_no="zkxw",
  1146. create_dept_id="",
  1147. create_time=now,
  1148. update_time=now,
  1149. del_flag=0,
  1150. )
  1151. )
  1152. self.db.commit()
  1153. def tearDown(self):
  1154. self.db.close()
  1155. self.engine.dispose()
  1156. def test_done_list_shows_instance_current_progress(self):
  1157. from app.services.oa_engine import list_tasks
  1158. done = list_tasks(self.db, tenant_no="zkxw", user_id="u1", kind="done")["list"]
  1159. self.assertEqual(len(done), 1)
  1160. row = done[0]
  1161. self.assertEqual(row["appName"], "请假审批")
  1162. self.assertEqual(row["initiatorUserName"], "发起人甲")
  1163. self.assertEqual(row["applyTime"], "2026-07-15 10:00:00")
  1164. self.assertEqual(row["instanceStatus"], "running")
  1165. self.assertEqual(row["currentNodeName"], "二级审批")
  1166. self.assertEqual(row["currentApproverNames"], "审批人丙")
  1167. self.assertEqual(row["status"], "approved")
  1168. def test_mine_list_enrichment(self):
  1169. from app.services.oa_engine import list_tasks
  1170. mine = list_tasks(self.db, tenant_no="zkxw", user_id="u0", kind="mine")["list"]
  1171. self.assertEqual(len(mine), 1)
  1172. row = mine[0]
  1173. self.assertEqual(row["appName"], "请假审批")
  1174. self.assertEqual(row["instanceStatus"], "running")
  1175. self.assertEqual(row["currentNodeName"], "二级审批")
  1176. self.assertEqual(row["currentApproverNames"], "审批人丙")
  1177. def test_pending_list_enrichment(self):
  1178. from app.services.oa_engine import list_tasks
  1179. pending = list_tasks(self.db, tenant_no="zkxw", user_id="u2", kind="pending")["list"]
  1180. self.assertEqual(len(pending), 1)
  1181. row = pending[0]
  1182. self.assertEqual(row["appName"], "请假审批")
  1183. self.assertEqual(row["currentNodeName"], "二级审批")
  1184. def test_terminal_instance_labels(self):
  1185. from app.services.oa_engine import list_tasks
  1186. from app.models.models import OaInstance
  1187. cases = [
  1188. ("completed", "已完成"),
  1189. ("rejected", "已拒绝"),
  1190. ("returned", "已打回"),
  1191. ("cancelled", "已撤销"),
  1192. ]
  1193. inst = self.db.query(OaInstance).filter_by(id="i1").first()
  1194. pending = self.db.query(self.OaTask).filter_by(id="t-pending").first()
  1195. for status, expected_label in cases:
  1196. with self.subTest(status=status):
  1197. inst.status = status
  1198. pending.status = "cancelled"
  1199. self.db.commit()
  1200. mine = list_tasks(self.db, tenant_no="zkxw", user_id="u0", kind="mine")["list"]
  1201. row = mine[0]
  1202. self.assertEqual(row["instanceStatus"], status)
  1203. self.assertEqual(row["currentNodeName"], expected_label)
  1204. self.assertEqual(row["currentApproverNames"], "-")
  1205. inst.status = "running"
  1206. pending.status = "pending"
  1207. self.db.commit()
  1208. class TestTransferTask(unittest.TestCase):
  1209. def setUp(self):
  1210. from sqlalchemy import create_engine
  1211. from sqlalchemy.orm import sessionmaker
  1212. from app.models.models import OaApp, OaAppVersion, OaInstance, OaInstanceEvent, OaTask, SysUser
  1213. self.engine = create_engine("sqlite:///:memory:")
  1214. OaApp.__table__.create(self.engine)
  1215. OaAppVersion.__table__.create(self.engine)
  1216. OaInstance.__table__.create(self.engine)
  1217. OaTask.__table__.create(self.engine)
  1218. OaInstanceEvent.__table__.create(self.engine)
  1219. SysUser.__table__.create(self.engine)
  1220. Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
  1221. self.db = Session()
  1222. self.OaTask = OaTask
  1223. self.OaInstance = OaInstance
  1224. self.OaAppVersion = OaAppVersion
  1225. self.SysUser = SysUser
  1226. now = datetime.now()
  1227. self.db.add(
  1228. OaApp(
  1229. id="app1",
  1230. group_id="",
  1231. app_name="测试应用",
  1232. icon_url="",
  1233. description="",
  1234. initiator_scope="all",
  1235. initiator_user_ids="[]",
  1236. admin_scope="all",
  1237. admin_user_ids="[]",
  1238. status="published",
  1239. tenant_no="zkxw",
  1240. create_dept_id="",
  1241. create_time=now,
  1242. update_time=now,
  1243. del_flag=0,
  1244. )
  1245. )
  1246. self.db.add(
  1247. OaAppVersion(
  1248. id="v1",
  1249. app_id="app1",
  1250. version_no=1,
  1251. status="published",
  1252. is_latest_published="1",
  1253. form_schema_json="{}",
  1254. flow_tree_json="{}",
  1255. advanced_settings_json="{}",
  1256. tenant_no="zkxw",
  1257. create_dept_id="",
  1258. create_time=now,
  1259. update_time=now,
  1260. del_flag=0,
  1261. )
  1262. )
  1263. self.db.add(
  1264. OaInstance(
  1265. id="i1",
  1266. app_id="app1",
  1267. app_version_id="v1",
  1268. title="t",
  1269. status="running",
  1270. initiator_user_id="u0",
  1271. initiator_dept_id="d1",
  1272. form_data_json="{}",
  1273. tenant_no="zkxw",
  1274. create_dept_id="",
  1275. create_time=now,
  1276. update_time=now,
  1277. del_flag=0,
  1278. current_round=1,
  1279. )
  1280. )
  1281. for uid, name in (("u1", "李四"), ("u2", "赵六"), ("u3", "钱七")):
  1282. self.db.add(
  1283. SysUser(
  1284. id=uid,
  1285. user_no=uid,
  1286. nick_name=name,
  1287. email="",
  1288. phone_no="",
  1289. password="",
  1290. status="1",
  1291. tenant_no="zkxw",
  1292. create_dept_id="",
  1293. create_time=now,
  1294. update_time=now,
  1295. del_flag=0,
  1296. )
  1297. )
  1298. self.db.add(
  1299. OaTask(
  1300. id="t1",
  1301. instance_id="i1",
  1302. node_key="a1",
  1303. node_name="审批人",
  1304. task_type="approval",
  1305. assignee_user_id="u1",
  1306. status="pending",
  1307. resolve_source="role",
  1308. sign_mode="and",
  1309. round_no=1,
  1310. tenant_no="zkxw",
  1311. order_num=1,
  1312. create_dept_id="",
  1313. create_time=now,
  1314. update_time=now,
  1315. del_flag=0,
  1316. )
  1317. )
  1318. self.db.add(
  1319. OaTask(
  1320. id="t2",
  1321. instance_id="i1",
  1322. node_key="a1",
  1323. node_name="审批人",
  1324. task_type="approval",
  1325. assignee_user_id="u3",
  1326. status="pending",
  1327. resolve_source="role",
  1328. sign_mode="and",
  1329. round_no=1,
  1330. tenant_no="zkxw",
  1331. order_num=2,
  1332. create_dept_id="",
  1333. create_time=now,
  1334. update_time=now,
  1335. del_flag=0,
  1336. )
  1337. )
  1338. self.db.commit()
  1339. def tearDown(self):
  1340. self.db.close()
  1341. self.engine.dispose()
  1342. def test_transfer_rejects_self(self):
  1343. from app.services.oa_engine import OaEngineError, transfer_task
  1344. with self.assertRaises(OaEngineError) as ctx:
  1345. transfer_task(
  1346. self.db, "t1", tenant_no="zkxw", user_id="u1", target_user_id="u1"
  1347. )
  1348. self.assertIn("自己", str(ctx.exception))
  1349. def test_transfer_rejects_existing_pending(self):
  1350. from app.services.oa_engine import OaEngineError, transfer_task
  1351. with self.assertRaises(OaEngineError) as ctx:
  1352. transfer_task(
  1353. self.db, "t1", tenant_no="zkxw", user_id="u1", target_user_id="u3"
  1354. )
  1355. self.assertIn("待办", str(ctx.exception))
  1356. def test_transfer_rejects_non_assignee(self):
  1357. from app.services.oa_engine import OaEngineError, transfer_task
  1358. with self.assertRaises(OaEngineError) as ctx:
  1359. transfer_task(
  1360. self.db, "t1", tenant_no="zkxw", user_id="u2", target_user_id="u2"
  1361. )
  1362. self.assertIn("无权", str(ctx.exception))
  1363. def test_transfer_success_and_keeps_co_approver(self):
  1364. from app.services.oa_engine import list_tasks, transfer_task
  1365. with patch(
  1366. "app.services.oa_engine.get_instance_detail",
  1367. return_value={"id": "i1", "status": "running"},
  1368. ):
  1369. transfer_task(
  1370. self.db,
  1371. "t1",
  1372. tenant_no="zkxw",
  1373. user_id="u1",
  1374. target_user_id="u2",
  1375. comment="请协助处理",
  1376. )
  1377. t1 = self.db.query(self.OaTask).filter(self.OaTask.id == "t1").first()
  1378. t2 = self.db.query(self.OaTask).filter(self.OaTask.id == "t2").first()
  1379. new_task = (
  1380. self.db.query(self.OaTask)
  1381. .filter(
  1382. self.OaTask.instance_id == "i1",
  1383. self.OaTask.assignee_user_id == "u2",
  1384. self.OaTask.status == "pending",
  1385. )
  1386. .first()
  1387. )
  1388. self.assertEqual(t1.status, "transferred")
  1389. self.assertEqual(t1.comment_text, "请协助处理")
  1390. self.assertEqual(t2.status, "pending")
  1391. self.assertIsNotNone(new_task)
  1392. self.assertEqual(new_task.resolve_source, "transfer")
  1393. self.assertEqual(new_task.sign_mode, "and")
  1394. self.assertEqual(new_task.node_key, "a1")
  1395. done = list_tasks(self.db, tenant_no="zkxw", user_id="u1", kind="done")["list"]
  1396. self.assertEqual(len(done), 1)
  1397. self.assertEqual(done[0]["status"], "transferred")
  1398. from app.services.oa_engine import build_progress_items
  1399. tasks = (
  1400. self.db.query(self.OaTask)
  1401. .filter(self.OaTask.instance_id == "i1", self.OaTask.del_flag == 0)
  1402. .all()
  1403. )
  1404. inst = self.db.query(self.OaInstance).filter(self.OaInstance.id == "i1").first()
  1405. name_map = {"u1": "李四", "u2": "赵六", "u3": "钱七"}
  1406. items = build_progress_items(
  1407. inst=inst, flow_root=None, tasks=tasks, name_map=name_map
  1408. )
  1409. card = next(i for i in items if i.get("kind") == "task")
  1410. transferred = next(p for p in card["people"] if p["status"] == "transferred")
  1411. self.assertEqual(transferred["nickName"], "李四")
  1412. self.assertEqual(transferred["transferToUserId"], "u2")
  1413. self.assertEqual(transferred["transferToUserName"], "赵六")
  1414. def test_transfer_comment_optional(self):
  1415. from app.services.oa_engine import transfer_task
  1416. with patch(
  1417. "app.services.oa_engine.get_instance_detail",
  1418. return_value={"id": "i1", "status": "running"},
  1419. ):
  1420. transfer_task(
  1421. self.db,
  1422. "t1",
  1423. tenant_no="zkxw",
  1424. user_id="u1",
  1425. target_user_id="u2",
  1426. )
  1427. t1 = self.db.query(self.OaTask).filter(self.OaTask.id == "t1").first()
  1428. self.assertEqual(t1.status, "transferred")
  1429. self.assertIsNone(t1.comment_text)
  1430. def test_transfer_chain(self):
  1431. from app.services.oa_engine import transfer_task
  1432. now = datetime.now()
  1433. self.db.add(
  1434. self.SysUser(
  1435. id="u4",
  1436. user_no="u4",
  1437. nick_name="孙八",
  1438. email="",
  1439. phone_no="",
  1440. password="",
  1441. status="1",
  1442. tenant_no="zkxw",
  1443. create_dept_id="",
  1444. create_time=now,
  1445. update_time=now,
  1446. del_flag=0,
  1447. )
  1448. )
  1449. self.db.commit()
  1450. with patch(
  1451. "app.services.oa_engine.get_instance_detail",
  1452. return_value={"id": "i1", "status": "running"},
  1453. ):
  1454. transfer_task(
  1455. self.db,
  1456. "t1",
  1457. tenant_no="zkxw",
  1458. user_id="u1",
  1459. target_user_id="u2",
  1460. )
  1461. u2_task = (
  1462. self.db.query(self.OaTask)
  1463. .filter(
  1464. self.OaTask.instance_id == "i1",
  1465. self.OaTask.assignee_user_id == "u2",
  1466. self.OaTask.status == "pending",
  1467. )
  1468. .first()
  1469. )
  1470. self.assertIsNotNone(u2_task)
  1471. with patch(
  1472. "app.services.oa_engine.get_instance_detail",
  1473. return_value={"id": "i1", "status": "running"},
  1474. ):
  1475. transfer_task(
  1476. self.db,
  1477. u2_task.id,
  1478. tenant_no="zkxw",
  1479. user_id="u2",
  1480. target_user_id="u4",
  1481. )
  1482. u2_task = self.db.query(self.OaTask).filter(self.OaTask.id == u2_task.id).first()
  1483. u4_task = (
  1484. self.db.query(self.OaTask)
  1485. .filter(
  1486. self.OaTask.instance_id == "i1",
  1487. self.OaTask.assignee_user_id == "u4",
  1488. self.OaTask.status == "pending",
  1489. )
  1490. .first()
  1491. )
  1492. self.assertEqual(u2_task.status, "transferred")
  1493. self.assertIsNotNone(u4_task)
  1494. self.assertEqual(u4_task.resolve_source, "transfer")
  1495. if __name__ == "__main__":
  1496. unittest.main()