test_oa_engine.py 50 KB

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