test_wechat_notice_bx_sources.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. """BX/充值通知快照:只取当前节点和真实审批结果,不把发放当审批。"""
  2. from datetime import date, datetime, timedelta
  3. import importlib
  4. import importlib.util
  5. import pytest
  6. from sqlalchemy import create_engine
  7. from sqlalchemy.orm import sessionmaker
  8. from app.models.bx_models import (
  9. BxAdvanceApply, BxAdvanceApproval, BxReimburseApply, BxReimburseApproval,
  10. BxWorkflowDef, BxWorkflowNode,
  11. )
  12. from app.models.pl_models import PlVehicleRechargeApply, PlVehicleRechargeApproval
  13. @pytest.fixture
  14. def db():
  15. engine = create_engine('sqlite://')
  16. for model in (BxAdvanceApply, BxAdvanceApproval, BxReimburseApply,
  17. BxReimburseApproval, BxWorkflowDef, BxWorkflowNode,
  18. PlVehicleRechargeApply, PlVehicleRechargeApproval):
  19. model.__table__.create(engine)
  20. session = sessionmaker(bind=engine, autoflush=False)()
  21. yield session
  22. session.close()
  23. engine.dispose()
  24. def snapshot(db, kind):
  25. name = 'app.services.wechat_notice_bx_sources'
  26. assert importlib.util.find_spec(name) is not None, 'BX/充值审批通知快照适配器尚未实现'
  27. return importlib.import_module(name).get_snapshot(db, kind, 'apply', 't1')
  28. def seed(db, kind, status=None, disburse_first=False):
  29. recharge = kind == 'pl_recharge'
  30. db.add(BxWorkflowDef(id='wf', biz_type='vehicle_recharge' if recharge else kind,
  31. wf_name='审批流程', tenant_no='t1'))
  32. for i in (1, 2):
  33. db.add(BxWorkflowNode(id=f'n{i}', wf_id='wf', node_name=f'审批{i}', node_order=i,
  34. approver_id=f'u{i}', tenant_no='t1',
  35. action_type='disburse' if recharge and (i == 2 or disburse_first) else 'approve'))
  36. fields = dict(id='apply', applicant_id='applicant', create_by='operator', tenant_no='t1',
  37. wf_id='wf', status=status or ('in_approval' if recharge else 'pending'),
  38. current_node='n1' if recharge else '审批1')
  39. if recharge:
  40. row = PlVehicleRechargeApply(**fields, apply_no='PL001', license_plate='鄂A00001',
  41. recharge_type='fuel', apply_date=date.today(), approval_round=2)
  42. else:
  43. fields.update(apply_code='BX001', applicant_name='申请人', amount=100)
  44. row = (BxAdvanceApply(**fields, reason='备用金') if kind == 'bx_advance' else
  45. BxReimburseApply(**fields, title='报销', reimburse_date=date.today(),
  46. expense_id='expense', expense_name='差旅'))
  47. db.add(row)
  48. db.flush()
  49. return row
  50. def approval(db, kind, outcome='approved', uid='u2', when=None, round_no=2):
  51. model = {'bx_advance': BxAdvanceApproval, 'bx_reimburse': BxReimburseApproval,
  52. 'pl_recharge': PlVehicleRechargeApproval}[kind]
  53. fields = dict(id=f'{outcome}-{uid}-{round_no}', apply_id='apply', tenant_no='t1',
  54. approver_id=uid, approver_name=uid, node_name='审批2',
  55. approval_status=outcome, approval_time=when or datetime(2026, 10, 27, 12))
  56. fields.update(dict(approval_round=round_no, node_id='n2') if kind == 'pl_recharge' else dict(node_order=2))
  57. record = model(**fields)
  58. db.add(record)
  59. db.flush()
  60. return record
  61. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
  62. def test_only_current_approver_and_real_applicant(db, kind):
  63. row = seed(db, kind)
  64. result = snapshot(db, kind)
  65. assert [(p.task_key, p.user_id) for p in result.pending] == [('n1', 'u1')]
  66. assert result.applicant_id == 'applicant'
  67. assert result.result is None
  68. assert result.approver_id == ''
  69. assert result.page == ''
  70. row.current_node = 'n2' if kind == 'pl_recharge' else '审批2'
  71. db.flush()
  72. result = snapshot(db, kind)
  73. assert [p.user_id for p in result.pending] == ([] if kind == 'pl_recharge' else ['u2'])
  74. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
  75. @pytest.mark.parametrize('state', ['draft', 'cancelled'])
  76. def test_inactive_row_suppresses_stale_node_and_approval(db, kind, state):
  77. seed(db, kind, status=state)
  78. approval(db, kind)
  79. result = snapshot(db, kind)
  80. assert result.pending == []
  81. assert result.result is None
  82. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
  83. def test_result_uses_latest_approval_time_not_node_order_or_operator(db, kind):
  84. row = seed(db, kind, status='rejected')
  85. approval(db, kind, when=datetime(2026, 10, 27, 11))
  86. last = approval(db, kind, outcome='rejected', uid='u1')
  87. result = snapshot(db, kind)
  88. assert result.result == 'rejected'
  89. assert result.pending == []
  90. assert result.approver_id == 'u1'
  91. assert result.occurred_at == last.approval_time
  92. assert result.page == {
  93. 'bx_advance': 'pages/bx/advance/detail?id=apply',
  94. 'bx_reimburse': 'pages/bx/reimburse/detail?id=apply',
  95. 'pl_recharge': 'pages/pl/vehicle-recharge-apply/form?id=apply',
  96. }[kind]
  97. row.status = 'in_approval' if kind == 'pl_recharge' else 'pending'
  98. db.flush()
  99. assert snapshot(db, kind).result is None
  100. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
  101. def test_bx_approved_requires_real_record(db, kind):
  102. seed(db, kind, status='approved')
  103. assert snapshot(db, kind).result is None
  104. record = approval(db, kind)
  105. result = snapshot(db, kind)
  106. assert result.result == 'approved'
  107. assert result.occurred_at == record.approval_time
  108. def test_recharge_approval_completes_before_disbursement(db):
  109. row = seed(db, 'pl_recharge')
  110. row.current_node = 'n2'
  111. db.flush()
  112. approval(db, 'pl_recharge', round_no=1)
  113. assert snapshot(db, 'pl_recharge').result is None
  114. approved = approval(db, 'pl_recharge')
  115. result = snapshot(db, 'pl_recharge')
  116. assert result.result == 'approved'
  117. assert result.pending == []
  118. assert result.approver_id == 'u2'
  119. assert result.occurred_at == approved.approval_time
  120. row.status = 'disbursed'
  121. approval(db, 'pl_recharge', outcome='disbursed', uid='finance',
  122. when=approved.approval_time + timedelta(hours=1))
  123. result = snapshot(db, 'pl_recharge')
  124. assert result.result == 'approved'
  125. assert result.approver_id == 'u2'
  126. assert result.occurred_at == approved.approval_time
  127. def test_recharge_direct_disbursement_does_not_invent_approval(db):
  128. row = seed(db, 'pl_recharge', disburse_first=True)
  129. assert snapshot(db, 'pl_recharge').pending == []
  130. assert snapshot(db, 'pl_recharge').result is None
  131. row.status = 'disbursed'
  132. approval(db, 'pl_recharge', outcome='disbursed', uid='finance')
  133. assert snapshot(db, 'pl_recharge').result is None
  134. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
  135. def test_tenant_and_soft_deleted_application_are_excluded(db, kind):
  136. row = seed(db, kind)
  137. row.tenant_no = 'other'
  138. db.flush()
  139. assert snapshot(db, kind) is None
  140. row.tenant_no = 't1'
  141. row.del_flag = 1
  142. db.flush()
  143. assert snapshot(db, kind) is None
  144. @pytest.fixture
  145. def live(db, monkeypatch):
  146. from app.core.bx_auth import BxUserContext
  147. from app.models.bx_models import BxInvoiceLedger, BxInvoiceWalletSummary
  148. from app.models.models import SysUser
  149. from app.models.wechat_notice import WechatApprovalCycle, WechatNotice
  150. from app.services import wechat_approval_notices as notices
  151. from app.services.bx import base
  152. from app.services import pl_vehicle_recharge_service as recharge
  153. from app.services import pl_vehicle_recharge_approval_service as recharge_approval
  154. for model in (SysUser, BxInvoiceLedger, BxInvoiceWalletSummary, WechatApprovalCycle, WechatNotice):
  155. model.__table__.create(db.get_bind())
  156. for uid in ('applicant', 'operator', 'u1', 'u2'):
  157. db.add(SysUser(id=uid, tenant_no='t1', user_no=uid, nick_name=uid))
  158. db.commit()
  159. monkeypatch.setattr(notices.settings, 'WECHAT_OA_ENABLED', True)
  160. monkeypatch.setattr(notices.settings, 'WECHAT_OA_TEMPLATE_PENDING', 'pending-template')
  161. monkeypatch.setattr(notices.settings, 'WECHAT_OA_TEMPLATE_RESULT', 'result-template')
  162. monkeypatch.setattr(base, 'assert_bx_row_in_scope', lambda *a, **kw: None)
  163. monkeypatch.setattr(recharge, 'assert_pl_row_in_scope', lambda *a, **kw: None)
  164. # Presentation is unrelated to transactional business mutations under test.
  165. monkeypatch.setattr(recharge, 'apply_to_read', lambda row, *a, **kw: row)
  166. monkeypatch.setattr(recharge_approval, 'apply_to_read', lambda row, *a, **kw: row)
  167. return (lambda uid: BxUserContext(uid, uid, 'd1', 'admin', 't1')), WechatNotice, WechatApprovalCycle
  168. def bx_service(kind):
  169. from app.services.bx.advance import AdvanceService
  170. from app.services.bx.reimburse import ReimburseService
  171. return AdvanceService if kind == 'bx_advance' else ReimburseService
  172. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
  173. def test_bx_real_submit_sequential_approve_notifies_applicant_and_rolls_back(db, live, kind):
  174. import json
  175. user, Notice, Cycle = live
  176. row = seed(db, kind, status='draft')
  177. db.commit()
  178. service = bx_service(kind)
  179. service.submit(db, row.id, user('operator'))
  180. assert db.query(Notice).filter_by(status='pending').one().recipient_id == 'u1'
  181. service.approve(db, row.id, user('u1'), '同意')
  182. assert db.query(Notice).filter_by(status='pending').one().recipient_id == 'u2'
  183. service.approve(db, row.id, user('u2'), '同意')
  184. result = db.query(Notice).filter_by(status='pending').one()
  185. assert result.kind == 'result'
  186. assert result.recipient_id == 'applicant'
  187. assert json.loads(result.payload)['thing6']['value'] == 'u2'
  188. assert json.loads(result.payload)['const5']['value'] == '通过'
  189. db.rollback()
  190. assert db.query(Notice).count() == 0
  191. assert db.query(Cycle).count() == 0
  192. assert row.status == 'draft'
  193. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
  194. def test_bx_real_withdraw_resubmit_and_reject_use_new_cycle(db, live, kind):
  195. user, Notice, Cycle = live
  196. row = seed(db, kind, status='draft')
  197. db.commit()
  198. service = bx_service(kind)
  199. service.submit(db, row.id, user('applicant'))
  200. first_cycle = db.query(Cycle).one().cycle_id
  201. service.withdraw(db, row.id, user('applicant'))
  202. assert db.query(Notice).filter_by(status='pending').count() == 0
  203. service.submit(db, row.id, user('operator'))
  204. assert db.query(Cycle).one().cycle_id != first_cycle
  205. service.reject(db, row.id, user('u1'), '请补充')
  206. result = db.query(Notice).filter_by(status='pending').one()
  207. assert result.kind == 'result'
  208. assert result.result == 'rejected'
  209. assert result.recipient_id == 'applicant'
  210. service.submit(db, row.id, user('applicant'))
  211. assert db.query(Notice).filter_by(status='pending').one().kind == 'pending'
  212. def test_recharge_real_approve_handoff_withdraw_resubmit_reject_cancel(db, live):
  213. from app.services import pl_vehicle_recharge_service as service
  214. from app.services import pl_vehicle_recharge_approval_service as approval_service
  215. user, Notice, Cycle = live
  216. row = seed(db, 'pl_recharge', status='draft')
  217. db.commit()
  218. service.submit_apply(db, 't1', user('operator'), row.id)
  219. assert db.query(Notice).filter_by(status='pending').one().recipient_id == 'u1'
  220. first_cycle = db.query(Cycle).one().cycle_id
  221. service.withdraw_apply(db, 't1', user('applicant'), row.id)
  222. assert db.query(Notice).filter_by(status='pending').count() == 0
  223. service.submit_apply(db, 't1', user('applicant'), row.id)
  224. assert db.query(Cycle).one().cycle_id != first_cycle
  225. approval_service.approve_apply(db, 't1', user('u1'), row.id)
  226. result = db.query(Notice).filter_by(status='pending').one()
  227. assert result.kind == 'result'
  228. assert result.result == 'approved'
  229. assert result.recipient_id == 'applicant'
  230. assert row.status == 'in_approval'
  231. service.withdraw_apply(db, 't1', user('applicant'), row.id)
  232. service.submit_apply(db, 't1', user('applicant'), row.id)
  233. approval_service.reject_apply(db, 't1', user('u1'), row.id, opinion='请补充')
  234. assert db.query(Notice).filter_by(status='pending').one().result == 'rejected'
  235. service.cancel_apply(db, 't1', user('operator'), row.id)
  236. assert db.query(Notice).filter_by(status='pending').count() == 0
  237. def test_recharge_real_direct_disburse_submission_has_no_pending_or_result(db, live):
  238. from app.services import pl_vehicle_recharge_service as service
  239. user, Notice, Cycle = live
  240. row = seed(db, 'pl_recharge', status='draft', disburse_first=True)
  241. db.commit()
  242. service.submit_apply(db, 't1', user('operator'), row.id)
  243. assert db.query(Cycle).count() == 1
  244. assert db.query(Notice).count() == 0
  245. def test_recharge_blank_action_is_approval_like_business_service(db):
  246. seed(db, 'pl_recharge')
  247. db.query(BxWorkflowNode).filter_by(id='n1').one().action_type = ' '
  248. db.flush()
  249. assert [p.user_id for p in snapshot(db, 'pl_recharge').pending] == ['u1']
  250. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
  251. def test_deleted_and_other_tenant_nodes_never_produce_tasks(db, kind):
  252. seed(db, kind)
  253. node = db.query(BxWorkflowNode).filter_by(id='n1').one()
  254. node.tenant_no = 'other'
  255. db.flush()
  256. assert snapshot(db, kind).pending == []
  257. node.tenant_no = 't1'
  258. node.del_flag = 1
  259. db.flush()
  260. assert snapshot(db, kind).pending == []
  261. @pytest.mark.parametrize('direct', [False, True])
  262. def test_real_disbursement_preserves_approval_result_and_adds_completion(db, live, direct):
  263. import json
  264. from app.models.pl_models import PlVehicleRechargeRecord
  265. from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
  266. from app.services import pl_vehicle_recharge_service as service
  267. from app.services import pl_vehicle_recharge_approval_service as approval_service
  268. from app.services.wechat_approval_notices import _valid_now
  269. user, Notice, _ = live
  270. PlVehicleRechargeRecord.__table__.create(db.get_bind())
  271. row = seed(db, 'pl_recharge', status='draft', disburse_first=direct)
  272. db.commit()
  273. service.submit_apply(db, 't1', user('operator'), row.id)
  274. if not direct:
  275. approval_service.approve_apply(db, 't1', user('u1'), row.id)
  276. approval_service.disburse_apply(
  277. db, 't1', user('u1' if direct else 'u2'), row.id,
  278. VehicleRechargeRecordCreate(rechargeDate=date.today(), rechargeType='fuel', amount=100),
  279. )
  280. assert row.status == 'disbursed'
  281. completion = db.query(Notice).filter_by(status='pending', kind='completion').one()
  282. assert json.loads(completion.payload)['thing3']['value'] == '充值发放'
  283. assert completion.recipient_id == 'applicant'
  284. assert _valid_now(db, completion) is True
  285. pending = db.query(Notice).filter_by(status='pending').filter(
  286. Notice.kind.in_(('pending', 'result'))
  287. ).all()
  288. if direct:
  289. assert pending == []
  290. else:
  291. assert len(pending) == 1
  292. assert pending[0].kind == 'result'
  293. assert json.loads(pending[0].payload)['thing6']['value'] == 'u1'
  294. assert _valid_now(db, pending[0]) is True
  295. def test_recharge_notification_failure_occurs_before_original_commit(db, live, monkeypatch):
  296. from app.services import pl_vehicle_recharge_service as service
  297. user, _, _ = live
  298. row = seed(db, 'pl_recharge', status='draft')
  299. db.commit()
  300. original_round = row.approval_round
  301. def failed_enqueue(*args, **kwargs):
  302. raise RuntimeError('transactional enqueue failed')
  303. monkeypatch.setattr(service, 'sync_approval_notifications', failed_enqueue)
  304. with pytest.raises(RuntimeError, match='transactional enqueue failed'):
  305. service.submit_apply(db, 't1', user('operator'), row.id)
  306. db.rollback()
  307. assert row.status == 'draft'
  308. assert row.approval_round == original_round
  309. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
  310. @pytest.mark.parametrize('operation', ['approve', 'reject'])
  311. def test_bx_rejects_nonpending_state_even_with_leftover_node(db, live, kind, operation):
  312. user, Notice, _ = live
  313. row = seed(db, kind, status='draft')
  314. db.commit()
  315. assert getattr(bx_service(kind), operation)(db, row.id, user('u1'), '重复请求') is None
  316. assert row.status == 'draft'
  317. assert db.query(Notice).count() == 0
  318. approval_model = BxAdvanceApproval if kind == 'bx_advance' else BxReimburseApproval
  319. assert db.query(approval_model).count() == 0
  320. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
  321. def test_bx_duplicate_submit_does_not_restart_active_cycle(db, live, kind):
  322. user, Notice, Cycle = live
  323. row = seed(db, kind, status='draft')
  324. db.commit()
  325. service = bx_service(kind)
  326. service.submit(db, row.id, user('applicant'))
  327. first_cycle = db.query(Cycle).one().cycle_id
  328. assert service.submit(db, row.id, user('applicant')) is None
  329. assert db.query(Cycle).one().cycle_id == first_cycle
  330. assert db.query(Notice).count() == 1
  331. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
  332. def test_bx_duplicate_terminal_approval_refreshes_cached_state(db, live, kind):
  333. from sqlalchemy import update
  334. user, Notice, _ = live
  335. row = seed(db, kind)
  336. row.current_node = '审批2'
  337. db.commit()
  338. assert row.current_node == '审批2'
  339. # 另一事务已完成审批;当前Session仍持有旧的identity-map对象。
  340. db.execute(update(type(row)).where(type(row).id == row.id).values(
  341. status='approved', current_node=None
  342. ).execution_options(synchronize_session=False))
  343. assert row.status == 'pending'
  344. assert bx_service(kind).approve(db, row.id, user('u2'), '重复终审') is None
  345. assert row.status == 'approved'
  346. approval_model = BxAdvanceApproval if kind == 'bx_advance' else BxReimburseApproval
  347. assert db.query(approval_model).count() == 0
  348. assert db.query(Notice).count() == 0
  349. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
  350. def test_submit_locks_business_row_before_state_change(db, live, kind):
  351. from sqlalchemy import event
  352. from sqlalchemy.dialects import mysql
  353. from app.services import pl_vehicle_recharge_service as recharge
  354. user, _, _ = live
  355. row = seed(db, kind, status='draft')
  356. db.commit()
  357. row_id = row.id
  358. selects = []
  359. locked_before_pending_write = []
  360. def capture(orm_state):
  361. if orm_state.is_select:
  362. sql = str(orm_state.statement.compile(dialect=mysql.dialect()))
  363. if f'FROM {row.__tablename__} ' in sql or f'FROM {row.__tablename__}\n' in sql:
  364. selects.append(sql)
  365. def before_flush(session, flush_context, instances):
  366. if row in session.dirty and row.status in ('pending', 'in_approval'):
  367. locked_before_pending_write.append(any('FOR UPDATE' in sql for sql in selects))
  368. event.listen(db, 'do_orm_execute', capture)
  369. event.listen(db, 'before_flush', before_flush)
  370. if kind == 'pl_recharge':
  371. recharge.submit_apply(db, 't1', user('applicant'), row_id)
  372. else:
  373. bx_service(kind).submit(db, row_id, user('applicant'))
  374. # A read-only lookup may precede the definition lock. The authoritative
  375. # business-row read must still lock and refresh before the pending write.
  376. assert locked_before_pending_write and all(locked_before_pending_write)
  377. def test_recharge_duplicate_terminal_approval_refreshes_cached_state(db, live):
  378. from fastapi import HTTPException
  379. from sqlalchemy import update
  380. from app.services import pl_vehicle_recharge_approval_service as service
  381. user, Notice, _ = live
  382. row = seed(db, 'pl_recharge')
  383. db.commit()
  384. assert row.current_node == 'n1'
  385. db.execute(update(type(row)).where(type(row).id == row.id).values(
  386. status='rejected', current_node=None
  387. ).execution_options(synchronize_session=False))
  388. assert row.status == 'in_approval'
  389. with pytest.raises(HTTPException) as exc:
  390. service.approve_apply(db, 't1', user('u1'), row.id)
  391. assert exc.value.status_code == 400
  392. assert row.status == 'rejected'
  393. assert db.query(PlVehicleRechargeApproval).count() == 0
  394. assert db.query(Notice).count() == 0
  395. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
  396. def test_pending_uses_web_approval_instead_of_nonactionable_mobile_detail(db, kind):
  397. seed(db, kind)
  398. assert snapshot(db, kind).page == ''