test_wechat_completion_notices.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. """真实业务事务中的完成通知;不连接生产、不调用微信。"""
  2. from datetime import date, datetime
  3. import importlib
  4. import importlib.util
  5. import json
  6. import pytest
  7. from sqlalchemy import create_engine, event
  8. from sqlalchemy.orm import sessionmaker
  9. from app.core.bx_auth import BxUserContext
  10. from app.core.config import settings
  11. from app.models.bx_models import BxAdvanceApply, BxReimburseApply, BxReimburseOffset, BxWorkflowNode
  12. from app.models.models import SysUser
  13. from app.models.pl_models import (
  14. PlFreightSettlement, PlFreightSettlementApproval, PlRemittanceVoucher,
  15. PlVehicleRechargeApply, PlVehicleRechargeApproval, PlVehicleRechargeRecord,
  16. )
  17. from app.models.wechat_notice import WechatNotice
  18. NOW = datetime(2026, 10, 27, 12, 30, 45)
  19. def service():
  20. name = 'app.services.wechat_completion_notices'
  21. assert importlib.util.find_spec(name), '完成通知尚未实现'
  22. return importlib.import_module(name)
  23. @pytest.fixture
  24. def db(monkeypatch):
  25. engine = create_engine('sqlite://')
  26. @event.listens_for(engine, 'connect')
  27. def connect(connection, _):
  28. connection.isolation_level = None
  29. @event.listens_for(engine, 'begin')
  30. def begin(connection):
  31. connection.exec_driver_sql('BEGIN')
  32. for model in (SysUser, WechatNotice, BxAdvanceApply, BxReimburseApply,
  33. BxReimburseOffset, BxWorkflowNode, PlFreightSettlement,
  34. PlFreightSettlementApproval, PlRemittanceVoucher,
  35. PlVehicleRechargeApply, PlVehicleRechargeApproval, PlVehicleRechargeRecord):
  36. model.__table__.create(engine)
  37. session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
  38. for uid in ('applicant', 'operator', 'submitter'):
  39. session.add(SysUser(id=uid, user_no=uid, nick_name=uid, tenant_no='t1'))
  40. session.commit()
  41. monkeypatch.setattr(settings, 'WECHAT_OA_ENABLED', True)
  42. monkeypatch.setattr(settings, 'WECHAT_OA_TEMPLATE_COMPLETION', 'completion-template')
  43. yield session
  44. session.close()
  45. engine.dispose()
  46. def seed(db, kind, *, completed=True, biz_id='doc'):
  47. common = dict(id=biz_id, tenant_no='t1', create_by='operator')
  48. if kind in ('bx_advance', 'bx_reimburse'):
  49. common.update(apply_code='BYJ20261027001' if kind == 'bx_advance' else 'BX20261027001',
  50. applicant_id='applicant', applicant_name='申请人', amount=100)
  51. if kind == 'bx_advance':
  52. row = BxAdvanceApply(**common, reason='备用金', status='issued' if completed else 'approved',
  53. issue_status='issued' if completed else None, issue_time=NOW if completed else None)
  54. else:
  55. row = BxReimburseApply(**common, title='报销', reimburse_date=date.today(), expense_id='expense',
  56. expense_name='费用', status='processed' if completed else 'approved',
  57. pay_status='paid' if completed else None, pay_time=NOW if completed else None)
  58. elif kind == 'pl_freight':
  59. row = PlFreightSettlement(**common, settlement_no='YF2026102701', total_freight=100,
  60. settlement_status='confirmed' if completed else 'approved')
  61. db.add(PlFreightSettlementApproval(id='approval', tenant_no='t1', settlement_id=biz_id,
  62. step=1, approver_id='operator', create_by='submitter', approval_result='approved'))
  63. if completed:
  64. db.add(PlRemittanceVoucher(id='record', tenant_no='t1', settlement_id=biz_id,
  65. remittance_date=date(2020, 1, 1), create_time=NOW))
  66. else:
  67. row = PlVehicleRechargeApply(**common, apply_no='ETC2026102701' + biz_id, applicant_id='applicant',
  68. license_plate='鄂A00001', recharge_type='etc', apply_date=date.today(),
  69. status='disbursed' if completed else 'pending')
  70. if completed:
  71. db.add(PlVehicleRechargeRecord(id='record', tenant_no='t1', apply_id=biz_id,
  72. recharge_order_no='SXCL20261027000001', recharge_date=date(2020, 1, 1),
  73. recharge_type='etc', license_plate='鄂A00001', amount=100, create_time=NOW))
  74. db.add(row)
  75. db.commit()
  76. return row
  77. def sync(db, kind, **kwargs):
  78. return service().sync_completion_notification(db, kind, 'doc', 't1', actor_id='operator', **kwargs)
  79. @pytest.mark.parametrize('kind,name,state', [
  80. ('bx_advance', '备用金发放', '发放完成'), ('bx_reimburse', '报销支付', '支付完成'),
  81. ('pl_freight', '运费发放', '发放完成'), ('pl_recharge', '充值发放', '发放完成'),
  82. ])
  83. def test_exact_payload_real_time_recipient_without_approval_cycle_and_rollback(db, kind, name, state):
  84. seed(db, kind)
  85. kwargs = {'event_id': 'record', 'occurred_at': NOW} if kind.startswith('pl_') else {}
  86. sync(db, kind, **kwargs)
  87. notice = db.query(WechatNotice).one()
  88. assert notice.recipient_id == ('submitter' if kind == 'pl_freight' else 'applicant')
  89. assert set(json.loads(notice.payload)) == {'thing3', 'thing4', 'thing5', 'time12'}
  90. assert json.loads(notice.payload)['thing3']['value'] == name
  91. assert json.loads(notice.payload)['thing5']['value'] == state
  92. assert json.loads(notice.payload)['time12']['value'] == '2026-10-27 12:30:45'
  93. assert service().valid_completion_notice(db, notice)
  94. sync(db, kind, **kwargs)
  95. assert db.query(WechatNotice).count() == 1
  96. db.rollback()
  97. assert db.query(WechatNotice).count() == 0
  98. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_freight', 'pl_recharge'])
  99. def test_status_only_approval_never_generates_completion(db, kind):
  100. seed(db, kind, completed=False)
  101. sync(db, kind, event_id='record', occurred_at=NOW)
  102. assert db.query(WechatNotice).count() == 0
  103. def test_full_number_is_not_truncated_and_invalid_records_are_auditable(db):
  104. row = seed(db, 'bx_advance')
  105. row.apply_code = 'X' * 21
  106. db.flush()
  107. sync(db, 'bx_advance')
  108. notice = db.query(WechatNotice).one()
  109. assert notice.status == 'failed'
  110. assert json.loads(notice.payload)['thing4']['value'] == 'X' * 21
  111. assert notice.last_error
  112. def test_recipient_and_tenant_or_deleted_document_revalidated(db):
  113. row = seed(db, 'bx_advance')
  114. sync(db, 'bx_advance')
  115. notice = db.query(WechatNotice).one()
  116. row.tenant_no = 'other'
  117. db.flush()
  118. assert not service().valid_completion_notice(db, notice)
  119. row.tenant_no = 't1'
  120. row.del_flag = 1
  121. db.flush()
  122. assert not service().valid_completion_notice(db, notice)
  123. row.del_flag = 0
  124. row.applicant_id = 'operator'
  125. db.flush()
  126. assert not service().valid_completion_notice(db, notice)
  127. @pytest.fixture
  128. def business(db, monkeypatch):
  129. from app.services.bx import base
  130. from app.services.bx.advance import AdvanceService
  131. from app.services.bx.reimburse import ReimburseService
  132. from app.services import pl_vehicle_recharge_service as recharge
  133. from app.services import pl_vehicle_recharge_approval_service as approval
  134. monkeypatch.setattr(base, 'assert_bx_row_in_scope', lambda *a, **kw: None)
  135. for cls in (AdvanceService, ReimburseService):
  136. monkeypatch.setattr(cls, '_tenant_no', lambda *a: 't1')
  137. monkeypatch.setattr(recharge, 'assert_pl_row_in_scope', lambda *a, **kw: None)
  138. monkeypatch.setattr(recharge, 'record_to_read', lambda row, *a, **kw: row)
  139. monkeypatch.setattr(recharge, 'apply_to_read', lambda row, *a, **kw: row)
  140. monkeypatch.setattr(approval, 'apply_to_read', lambda row, *a, **kw: row)
  141. return BxUserContext('operator', '经办人', 'dept', 'admin', 't1')
  142. @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
  143. def test_real_bx_complete_hook_retry_preserves_original_time_and_rolls_back(db, business, kind):
  144. from app.services.bx.advance import AdvanceService
  145. from app.services.bx.reimburse import ReimburseService
  146. row = seed(db, kind, completed=False)
  147. fn = AdvanceService.issue if kind == 'bx_advance' else ReimburseService.pay
  148. fn(db, row.id, business)
  149. assert db.query(WechatNotice).count() == 1
  150. payload = db.query(WechatNotice).one().payload
  151. stamp = row.issue_time if kind == 'bx_advance' else row.pay_time
  152. fn(db, row.id, business)
  153. assert (row.issue_time if kind == 'bx_advance' else row.pay_time) == stamp
  154. assert db.query(WechatNotice).count() == 1
  155. assert db.query(WechatNotice).one().payload == payload
  156. db.rollback()
  157. assert db.query(WechatNotice).count() == 0
  158. assert (row.issue_time if kind == 'bx_advance' else row.pay_time) is None
  159. def test_batch_offset_only_notifies_real_payment_and_deduplicates_offsets(db, business):
  160. from app.services.bx.reimburse import ReimburseService
  161. row = seed(db, 'bx_reimburse', completed=False)
  162. items = [{'advance_id': 'advance', 'offset_amount': 30}]
  163. ReimburseService.batch_offset(db, row.id, items, 70, business)
  164. assert db.query(WechatNotice).count() == 1
  165. ReimburseService.batch_offset(db, row.id, items, 70, business)
  166. assert db.query(BxReimburseOffset).count() == 1
  167. db.rollback()
  168. ReimburseService.batch_offset(db, row.id, items, 0, business)
  169. assert db.query(WechatNotice).count() == 0
  170. assert row.pay_time is None
  171. def test_freight_real_disbursement_notifies_submitter_with_voucher_time(db, business):
  172. from app.services.pl_freight_settlement_service import disburse_settlement
  173. from app.schemas.pl.freight import FreightSettlementDisburseBody
  174. row = seed(db, 'pl_freight', completed=False)
  175. disburse_settlement(db, tenant_no='t1', settlement_id=row.id,
  176. body=FreightSettlementDisburseBody(remittance_date=date(2020, 1, 1), remittance_amount=100, voucher_urls=['proof']),
  177. user_id='operator', dept_id='dept')
  178. notice = db.query(WechatNotice).one()
  179. voucher = db.query(PlRemittanceVoucher).one()
  180. assert row.settlement_status == 'confirmed'
  181. assert notice.recipient_id == 'submitter'
  182. assert notice.cycle_id == voucher.id
  183. assert json.loads(notice.payload)['time12']['value'] == voucher.create_time.strftime('%Y-%m-%d %H:%M:%S')
  184. assert service().valid_completion_notice(db, notice)
  185. voucher.del_flag = 1
  186. db.flush()
  187. assert not service().valid_completion_notice(db, notice)
  188. def payload(apply_id='doc'):
  189. from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
  190. return VehicleRechargeRecordCreate(apply_id=apply_id, license_plate='鄂A00001', recharge_type='etc',
  191. recharge_date=date(2020, 1, 1), amount=100)
  192. def test_recharge_create_delete_reissue_and_no_applicant(db, business, monkeypatch):
  193. from app.services import pl_vehicle_recharge_service as recharge
  194. row = seed(db, 'pl_recharge', completed=False)
  195. record = recharge.create_record(db, 't1', business, payload())
  196. notice = db.query(WechatNotice).one()
  197. assert service().valid_completion_notice(db, notice)
  198. recharge.delete_records(db, 't1', business, [record.id])
  199. assert notice.status == 'cancelled'
  200. assert not service().valid_completion_notice(db, notice)
  201. recharge.create_record(db, 't1', business, payload())
  202. assert db.query(WechatNotice).filter_by(status='pending').count() == 1
  203. assert db.query(WechatNotice).count() == 2
  204. monkeypatch.setattr(recharge, '_assert_vehicle_exists', lambda *a: None)
  205. recharge.create_record(db, 't1', business, payload(None))
  206. assert db.query(WechatNotice).count() == 2
  207. @pytest.mark.parametrize('old_status', ['pending', 'sending'])
  208. def test_recharge_same_second_reassociation_has_independent_event_and_cancels_old(db, business, monkeypatch, old_status):
  209. from app.services import pl_vehicle_recharge_service as recharge
  210. class FrozenDateTime(datetime):
  211. @classmethod
  212. def now(cls, tz=None):
  213. return NOW
  214. monkeypatch.setattr(recharge, 'datetime', FrozenDateTime)
  215. first = seed(db, 'pl_recharge', completed=False)
  216. second = seed(db, 'pl_recharge', completed=False, biz_id='other')
  217. record = recharge.create_record(db, 't1', business, payload())
  218. original = db.query(WechatNotice).one()
  219. original.status = old_status
  220. db.commit()
  221. recharge.update_record(db, 't1', business, record.id, payload(second.id))
  222. recharge.update_record(db, 't1', business, record.id, payload(first.id))
  223. assert db.query(WechatNotice).count() == 3
  224. assert db.query(WechatNotice).filter_by(status='pending').count() == 1
  225. assert not service().valid_completion_notice(db, original)
  226. assert service().valid_completion_notice(db, db.query(WechatNotice).filter_by(status='pending').one())
  227. def test_recharge_approval_disbursement_hook(db, business):
  228. from app.services.pl_vehicle_recharge_approval_service import disburse_apply
  229. row = seed(db, 'pl_recharge', completed=False)
  230. row.status, row.current_node, row.wf_id = 'in_approval', 'node', 'wf'
  231. db.add(BxWorkflowNode(id='node', wf_id='wf', node_name='发放', node_order=1,
  232. approver_id='operator', action_type='disburse', tenant_no='t1'))
  233. db.commit()
  234. disburse_apply(db, 't1', business, row.id, payload())
  235. notice = db.query(WechatNotice).one()
  236. assert service().valid_completion_notice(db, notice)
  237. assert notice.recipient_id == 'applicant'
  238. def test_recharge_disabled_still_cancels_old_pending_but_does_not_enqueue(db, business, monkeypatch):
  239. from app.services import pl_vehicle_recharge_service as recharge
  240. seed(db, 'pl_recharge', completed=False)
  241. record = recharge.create_record(db, 't1', business, payload())
  242. notice = db.query(WechatNotice).one()
  243. monkeypatch.setattr(settings, 'WECHAT_OA_ENABLED', False)
  244. recharge.delete_records(db, 't1', business, [record.id])
  245. recharge.create_record(db, 't1', business, payload())
  246. assert notice.status == 'cancelled'
  247. assert db.query(WechatNotice).count() == 1
  248. def test_disabled_before_notification_schema_install_does_not_block_business(db, business, monkeypatch):
  249. from app.services.bx.advance import AdvanceService
  250. row = seed(db, 'bx_advance', completed=False)
  251. WechatNotice.__table__.drop(db.get_bind())
  252. monkeypatch.setattr(settings, 'WECHAT_OA_ENABLED', False)
  253. AdvanceService.issue(db, row.id, business)
  254. assert row.status == 'issued'
  255. def test_completed_before_launch_is_not_retroactively_notified(db, business):
  256. from app.services.bx.advance import AdvanceService
  257. row = seed(db, 'bx_advance')
  258. AdvanceService.issue(db, row.id, business)
  259. assert db.query(WechatNotice).count() == 0
  260. def test_invalid_recipient_never_enqueues(db):
  261. row = seed(db, 'bx_advance')
  262. row.applicant_id = 'outsider'
  263. db.add(SysUser(id='outsider', user_no='outsider', nick_name='外租户', tenant_no='other'))
  264. db.flush()
  265. sync(db, 'bx_advance')
  266. assert db.query(WechatNotice).count() == 0
  267. row.applicant_id = 'applicant'
  268. db.query(SysUser).filter_by(id='applicant').one().status = '0'
  269. db.flush()
  270. sync(db, 'bx_advance')
  271. assert db.query(WechatNotice).count() == 0
  272. def test_stale_sender_cannot_reactivate_revoked_recharge_association(db, business):
  273. from app.services import pl_vehicle_recharge_service as recharge
  274. first = seed(db, 'pl_recharge', completed=False)
  275. second = seed(db, 'pl_recharge', completed=False, biz_id='other')
  276. record = recharge.create_record(db, 't1', business, payload())
  277. original = db.query(WechatNotice).one()
  278. original.status = 'sending'
  279. db.commit()
  280. recharge.update_record(db, 't1', business, record.id, payload(second.id))
  281. recharge.update_record(db, 't1', business, record.id, payload(first.id))
  282. # 网络在撤销前已开始时,旧worker可能把瞬时失败重新写为pending。
  283. original.status = 'pending'
  284. db.flush()
  285. assert not service().valid_completion_notice(db, original)
  286. def test_freight_disbursement_consumes_one_locked_current_read(db, business, monkeypatch):
  287. from app.services import pl_freight_settlement_service as freight
  288. from app.schemas.pl.freight import FreightSettlementDisburseBody
  289. row = seed(db, 'pl_freight', completed=False)
  290. reads = []
  291. @event.listens_for(db, 'do_orm_execute')
  292. def track_reads(execute_state):
  293. statement = execute_state.statement
  294. if execute_state.is_select and any(
  295. entry.get('entity') is PlFreightSettlement
  296. for entry in getattr(statement, 'column_descriptions', [])
  297. ):
  298. reads.append(statement._for_update_arg is not None)
  299. # 只隔离通知快照及展示刷新读取;真实发放状态、凭证与提交仍执行。
  300. monkeypatch.setattr(freight, 'sync_completion_notification', lambda *a, **kw: None)
  301. monkeypatch.setattr(db, 'refresh', lambda *a, **kw: None)
  302. freight.disburse_settlement(db, tenant_no='t1', settlement_id=row.id,
  303. body=FreightSettlementDisburseBody(remittance_date=date.today(), remittance_amount=100, voucher_urls=['proof']),
  304. user_id='operator', dept_id='dept')
  305. assert reads == [True], '状态校验必须直接使用唯一锁读,不能随后退回普通快照读'
  306. def test_freight_locked_get_refreshes_stale_identity_map(db):
  307. from app.services.pl_freight_settlement_service import get_settlement_row
  308. row = seed(db, 'pl_freight', completed=False)
  309. db.execute(PlFreightSettlement.__table__.update().where(
  310. PlFreightSettlement.id == row.id,
  311. ).values(settlement_status='confirmed'))
  312. assert row.settlement_status == 'approved'
  313. current = get_settlement_row(db, 't1', row.id, for_update=True)
  314. assert current is row
  315. assert current.settlement_status == 'confirmed'
  316. def test_cancelled_freight_completion_is_not_revived(db):
  317. seed(db, 'pl_freight')
  318. notice = sync(db, 'pl_freight', event_id='record')
  319. assert notice is not None
  320. notice.status = 'cancelled'
  321. notice.result = 'revoked'
  322. db.flush()
  323. sync(db, 'pl_freight', event_id='record')
  324. assert db.query(WechatNotice).count() == 1
  325. assert notice.status == 'cancelled'
  326. assert not service().valid_completion_notice(db, notice)