test_market_payment_api.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861
  1. from datetime import date, datetime, timedelta
  2. from decimal import Decimal
  3. from types import SimpleNamespace
  4. import pytest
  5. from fastapi import HTTPException
  6. from fastapi.encoders import jsonable_encoder
  7. from pydantic import ValidationError
  8. from sqlalchemy import create_engine, event
  9. from sqlalchemy.orm import sessionmaker
  10. from starlette.responses import JSONResponse
  11. from app.models.base import Base
  12. from app.models.models import (
  13. BizAttachment,
  14. BizCellSampleType,
  15. BizCollectionProtocol,
  16. BizCollectionRegionStaff,
  17. BizProtocolCollectionInfo,
  18. BizProtocolPartyCustomer,
  19. BizProtocolPaymentSchedule,
  20. BizProtocolPaymentTransfer,
  21. BizProtocolSample,
  22. BizProtocolSamplePayment,
  23. SysPost,
  24. SysPostRole,
  25. SysRole,
  26. SysRoleDataScopeDetail,
  27. SysUser,
  28. SysUserPost,
  29. SysUserRole,
  30. )
  31. def _session():
  32. engine = create_engine('sqlite:///:memory:')
  33. Base.metadata.create_all(
  34. engine,
  35. tables=[
  36. BizCollectionProtocol.__table__,
  37. BizProtocolCollectionInfo.__table__,
  38. BizProtocolPartyCustomer.__table__,
  39. BizProtocolSamplePayment.__table__,
  40. BizProtocolPaymentSchedule.__table__,
  41. BizProtocolPaymentTransfer.__table__,
  42. BizProtocolSample.__table__,
  43. BizCellSampleType.__table__,
  44. BizAttachment.__table__,
  45. BizCollectionRegionStaff.__table__,
  46. SysUser.__table__,
  47. SysRole.__table__,
  48. SysRoleDataScopeDetail.__table__,
  49. SysUserRole.__table__,
  50. SysPost.__table__,
  51. SysPostRole.__table__,
  52. SysUserPost.__table__,
  53. ],
  54. )
  55. return sessionmaker(bind=engine)()
  56. def _ctx(scope_kind='specialist', user_id='u1'):
  57. from app.services.market_field_scope import MarketFieldContext
  58. return MarketFieldContext('tenant-a', user_id, scope_kind)
  59. def _user(user_id='u1'):
  60. return {
  61. 'user_id': user_id,
  62. 'tenant_no': 'tenant-a',
  63. 'role': 'super_admin',
  64. 'region_id': 'dept-from-auth',
  65. }
  66. def _payment_tree(
  67. db,
  68. key,
  69. *,
  70. sales='u1',
  71. manager='manager-a',
  72. region='region-a',
  73. tenant='tenant-a',
  74. protocol_status='sxz',
  75. payment_model_version=2,
  76. due_date=None,
  77. due_amount=100,
  78. schedule_status='open',
  79. deleted=0,
  80. transfer_statuses=(),
  81. transfer_dept='',
  82. register_date='20261001',
  83. storage_years=20,
  84. sample_price=80,
  85. sample_original_price=100,
  86. ):
  87. protocol = BizCollectionProtocol(
  88. id=f'p-{key}',
  89. protocol_no=f'P-{key}',
  90. protocol_status=protocol_status,
  91. payment_model_version=payment_model_version,
  92. register_date=register_date,
  93. tenant_no=tenant,
  94. create_dept_id='dept-a',
  95. create_time=datetime.now(),
  96. del_flag=deleted,
  97. )
  98. collection = BizProtocolCollectionInfo(
  99. id=f'ci-{key}',
  100. collection_protocol_id=protocol.id,
  101. region_id=region,
  102. sales_user_id=sales,
  103. region_manager_user_id=manager,
  104. tenant_no=tenant,
  105. create_dept_id='dept-a',
  106. del_flag=deleted,
  107. )
  108. party = BizProtocolPartyCustomer(
  109. id=f'party-{key}',
  110. collection_protocol_id=protocol.id,
  111. mother_name=f'客户-{key}',
  112. tenant_no=tenant,
  113. create_dept_id='dept-a',
  114. del_flag=deleted,
  115. )
  116. payment = BizProtocolSamplePayment(
  117. id=f'sp-{key}',
  118. collection_protocol_id=protocol.id,
  119. sample_code=f'S-{key}',
  120. pay_mode='fq',
  121. contract_amount=due_amount,
  122. tenant_no=tenant,
  123. create_dept_id='dept-a',
  124. create_time=datetime.now(),
  125. del_flag=deleted,
  126. )
  127. schedule = BizProtocolPaymentSchedule(
  128. id=f'sch-{key}',
  129. sample_payment_id=payment.id,
  130. collection_protocol_id=protocol.id,
  131. sample_code=payment.sample_code,
  132. schedule_no=1,
  133. due_amount=due_amount,
  134. due_date=due_date or date.today().strftime('%Y%m%d'),
  135. schedule_status=schedule_status,
  136. tenant_no=tenant,
  137. create_dept_id='dept-a',
  138. del_flag=deleted,
  139. )
  140. sample_type = BizCellSampleType(
  141. id=f'type-{key}',
  142. type_code=f'TYPE-{key}',
  143. type_name=f'样本类型-{key}',
  144. tenant_no=tenant,
  145. create_dept_id='dept-a',
  146. del_flag=deleted,
  147. )
  148. sample = BizProtocolSample(
  149. id=f'sample-{key}',
  150. collection_protocol_id=protocol.id,
  151. sample_type_id=sample_type.id,
  152. sample_code=payment.sample_code,
  153. storage_years=storage_years,
  154. price=sample_price,
  155. original_price=sample_original_price,
  156. tenant_no=tenant,
  157. create_dept_id='dept-a',
  158. del_flag=deleted,
  159. )
  160. db.add_all([protocol, collection, party, payment, schedule, sample_type, sample])
  161. for index, status in enumerate(transfer_statuses):
  162. db.add(
  163. BizProtocolPaymentTransfer(
  164. id=f't-{key}-{index}',
  165. schedule_id=schedule.id,
  166. sample_payment_id=payment.id,
  167. collection_protocol_id=protocol.id,
  168. sample_code=payment.sample_code,
  169. transfer_amount=20,
  170. transfer_time=datetime.now(),
  171. transfer_type='bank',
  172. pay_status=status,
  173. tenant_no=tenant,
  174. create_dept_id=transfer_dept,
  175. del_flag=deleted,
  176. )
  177. )
  178. db.commit()
  179. return protocol, payment, schedule
  180. def test_chase_is_overdue_union_next_30_days_with_parent_scope():
  181. from app.services.market_payment import list_payment_chase
  182. db = _session()
  183. today = date(2026, 10, 11)
  184. _payment_tree(db, 'overdue', due_date=(today - timedelta(days=1)).strftime('%Y%m%d'))
  185. _payment_tree(db, 'today', due_date=today.strftime('%Y%m%d'))
  186. _payment_tree(db, 'day30', due_date=(today + timedelta(days=30)).strftime('%Y%m%d'))
  187. _payment_tree(db, 'day31', due_date=(today + timedelta(days=31)).strftime('%Y%m%d'))
  188. _payment_tree(db, 'other', sales='u2', due_date=today.strftime('%Y%m%d'))
  189. _payment_tree(db, 'void', protocol_status='yzf', due_date=today.strftime('%Y%m%d'))
  190. _payment_tree(db, 'bad-date', due_date='not-a-date')
  191. items, total = list_payment_chase(
  192. db, ctx=_ctx(), user_info=_user(), today=today
  193. )
  194. assert total == 3
  195. assert {item['scheduleId'] for item in items} == {
  196. 'sch-overdue',
  197. 'sch-today',
  198. 'sch-day30',
  199. }
  200. def test_payment_chase_uses_historical_nickname_without_user_no_fallback():
  201. from app.services.market_payment import list_payment_chase
  202. db = _session()
  203. db.add_all(
  204. [
  205. SysUser(id='u1', user_no='deleted-sales-no', nick_name='历史删除专员', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1),
  206. SysUser(id='blank-sales', user_no='raw-sales-no', nick_name='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1),
  207. ]
  208. )
  209. _payment_tree(db, 'historical-sales', sales='u1')
  210. _payment_tree(db, 'blank-sales', sales='blank-sales')
  211. historical_items, historical_total = list_payment_chase(
  212. db, ctx=_ctx(user_id='u1'), user_info=_user('u1')
  213. )
  214. blank_items, blank_total = list_payment_chase(
  215. db,
  216. ctx=_ctx(user_id='blank-sales'),
  217. user_info=_user('blank-sales'),
  218. )
  219. assert historical_total == 1
  220. assert historical_items[0]['salesUserName'] == '历史删除专员'
  221. assert blank_total == 1
  222. assert blank_items[0]['salesUserName'] == ''
  223. def test_chase_counts_only_yjf_and_excludes_fully_confirmed():
  224. from app.services.market_payment import list_payment_chase
  225. db = _session()
  226. _payment_tree(
  227. db,
  228. 'part-paid',
  229. due_amount=100,
  230. transfer_statuses=('dfj', 'jfqrz', 'yjf'),
  231. )
  232. _payment_tree(
  233. db,
  234. 'fully-paid',
  235. due_amount=60,
  236. transfer_statuses=('yjf', 'yjf', 'yjf'),
  237. )
  238. items, total = list_payment_chase(db, ctx=_ctx(), user_info=_user())
  239. assert total == 1
  240. assert items[0]['scheduleId'] == 'sch-part-paid'
  241. assert items[0]['paidAmount'] == '20.00'
  242. assert items[0]['arrearsAmount'] == '80.00'
  243. def test_chase_filters_counts_orders_and_pages_in_sql_before_materialization():
  244. from app.services.market_payment import list_payment_chase
  245. db = _session()
  246. today = date(2026, 10, 11)
  247. for index in range(35):
  248. _payment_tree(
  249. db,
  250. f'history-{index:02d}',
  251. due_date=(today - timedelta(days=index + 1)).strftime('%Y%m%d'),
  252. transfer_statuses=('dfj',),
  253. )
  254. _payment_tree(db, 'invalid-day', due_date='20260230')
  255. statements = []
  256. def capture(_conn, _cursor, statement, _parameters, _context, _many):
  257. statements.append(statement)
  258. event.listen(db.get_bind(), 'before_cursor_execute', capture)
  259. try:
  260. items, total = list_payment_chase(
  261. db,
  262. ctx=_ctx(),
  263. user_info=_user(),
  264. today=today,
  265. page=3,
  266. page_size=5,
  267. )
  268. finally:
  269. event.remove(db.get_bind(), 'before_cursor_execute', capture)
  270. assert total == 35
  271. assert len(items) == 5
  272. assert [item['scheduleId'] for item in items] == [
  273. 'sch-history-24',
  274. 'sch-history-23',
  275. 'sch-history-22',
  276. 'sch-history-21',
  277. 'sch-history-20',
  278. ]
  279. chase_selects = [sql for sql in statements if 'biz_protocol_payment_schedule' in sql]
  280. assert len(chase_selects) == 2
  281. assert ' LIMIT ' in chase_selects[-1].upper()
  282. assert 'SUM(CASE' in chase_selects[-1].upper()
  283. assert not any(
  284. 'FROM biz_protocol_payment_transfer' in sql and 'SUM(CASE' not in sql.upper()
  285. for sql in statements
  286. )
  287. def test_payment_list_is_v2_only_and_uses_parent_scope_not_transfer_department():
  288. from app.services.market_payment import list_payments
  289. db = _session()
  290. _payment_tree(db, 'v2', transfer_statuses=('dfj',), transfer_dept='')
  291. _payment_tree(db, 'v1', payment_model_version=1)
  292. _payment_tree(db, 'other', sales='u2')
  293. _payment_tree(db, 'cross', tenant='tenant-b')
  294. _payment_tree(db, 'deleted', deleted=1)
  295. items, total = list_payments(db, ctx=_ctx(), user_info=_user())
  296. assert total == 1
  297. assert items[0]['id'] == 'sp-v2'
  298. assert items[0]['paymentModelVersion'] == 2
  299. def test_manager_scope_is_fixed_by_route_context_and_client_filter_cannot_expand():
  300. from app.services.market_payment import list_payment_chase
  301. db = _session()
  302. _payment_tree(db, 'manager', manager='manager-a', sales='u2')
  303. _payment_tree(db, 'outside', manager='manager-b', sales='u1')
  304. items, total = list_payment_chase(
  305. db,
  306. ctx=_ctx('manager', 'manager-a'),
  307. user_info=_user('manager-a'),
  308. sales_user_id='u1',
  309. )
  310. assert total == 0
  311. assert items == []
  312. def test_payment_detail_rejects_out_of_scope_sample():
  313. from app.services.market_payment import get_payment_detail
  314. db = _session()
  315. _payment_tree(db, 'other', sales='u2')
  316. with pytest.raises(HTTPException) as exc:
  317. get_payment_detail(
  318. db, payment_id='sp-other', ctx=_ctx(), user_info=_user()
  319. )
  320. assert exc.value.status_code == 404
  321. def test_payment_detail_identifies_legacy_v1_without_reading_wrong_tables():
  322. from app.services.market_payment import get_payment_detail
  323. db = _session()
  324. _payment_tree(db, 'legacy', payment_model_version=1)
  325. with pytest.raises(HTTPException) as exc:
  326. get_payment_detail(
  327. db, payment_id='sp-legacy', ctx=_ctx(), user_info=_user()
  328. )
  329. assert exc.value.status_code == 409
  330. assert '旧版' in str(exc.value.detail)
  331. def test_market_can_save_submit_and_withdraw_transfer_atomically():
  332. from app.services.market_payment import (
  333. create_transfer,
  334. submit_transfer,
  335. update_transfer,
  336. withdraw_transfer,
  337. )
  338. db = _session()
  339. _, _, schedule = _payment_tree(db, 'own')
  340. db.add(
  341. BizAttachment(
  342. id='att-pay',
  343. business_type='market:specialist:payment:staging',
  344. original_name='a.jpg',
  345. file_ext='jpg',
  346. file_url='a.jpg',
  347. file_size=10,
  348. file_type='image/jpeg',
  349. tenant_no='tenant-a',
  350. create_by='u1',
  351. create_dept_id='',
  352. del_flag=0,
  353. )
  354. )
  355. db.commit()
  356. created = create_transfer(
  357. db,
  358. schedule_id=schedule.id,
  359. fields={
  360. 'transferAmount': 50,
  361. 'transferTime': '2026-10-11 10:00:00',
  362. 'transferType': 'bank',
  363. 'transferAttachments': 'a.jpg',
  364. },
  365. ctx=_ctx(),
  366. user_info=_user(),
  367. )
  368. assert created['payStatus'] == 'dfj'
  369. row = db.get(BizProtocolPaymentTransfer, created['id'])
  370. assert row.create_dept_id == 'dept-from-auth'
  371. attachment = db.get(BizAttachment, 'att-pay')
  372. assert (attachment.business_type, attachment.business_id) == (
  373. 'market:specialist:payment:bound',
  374. created['id'],
  375. )
  376. updated = update_transfer(
  377. db,
  378. transfer_id=created['id'],
  379. fields={'remark': '补充说明'},
  380. ctx=_ctx(),
  381. user_info=_user(),
  382. )
  383. assert updated['remark'] == '补充说明'
  384. assert submit_transfer(
  385. db, transfer_id=created['id'], ctx=_ctx(), user_info=_user()
  386. )['payStatus'] == 'jfqrz'
  387. assert withdraw_transfer(
  388. db, transfer_id=created['id'], ctx=_ctx(), user_info=_user()
  389. )['payStatus'] == 'dfj'
  390. def test_transfer_create_rejects_external_attachment_reference():
  391. from app.services.market_payment import create_transfer
  392. db = _session()
  393. _, _, schedule = _payment_tree(db, 'attachment-reject')
  394. with pytest.raises(ValueError, match='附件'):
  395. create_transfer(
  396. db,
  397. schedule_id=schedule.id,
  398. fields={
  399. 'transferAmount': 10,
  400. 'transferType': 'bank',
  401. 'transferAttachments': 'https://evil.test/a.jpg',
  402. },
  403. ctx=_ctx(),
  404. user_info=_user(),
  405. )
  406. def test_transfer_create_rollback_restores_staging_attachment_on_business_failure(
  407. monkeypatch,
  408. ):
  409. from app.services.market_payment import create_transfer
  410. db = _session()
  411. _, _, schedule = _payment_tree(db, 'attachment-rollback')
  412. attachment = BizAttachment(
  413. id='att-rollback',
  414. business_type='market:specialist:payment:staging',
  415. original_name='rollback.jpg',
  416. file_ext='jpg',
  417. file_url='rollback.jpg',
  418. file_size=10,
  419. file_type='image/jpeg',
  420. tenant_no='tenant-a',
  421. create_by='u1',
  422. create_dept_id='',
  423. del_flag=0,
  424. )
  425. db.add(attachment)
  426. db.commit()
  427. monkeypatch.setattr(
  428. db, 'commit', lambda: (_ for _ in ()).throw(RuntimeError('db down'))
  429. )
  430. with pytest.raises(RuntimeError, match='db down'):
  431. create_transfer(
  432. db,
  433. schedule_id=schedule.id,
  434. fields={
  435. 'transferAmount': 10,
  436. 'transferType': 'bank',
  437. 'transferAttachments': 'att-rollback',
  438. },
  439. ctx=_ctx(),
  440. user_info=_user(),
  441. )
  442. db.expire_all()
  443. restored = db.get(BizAttachment, 'att-rollback')
  444. assert (restored.business_type, restored.business_id) == (
  445. 'market:specialist:payment:staging',
  446. None,
  447. )
  448. assert db.query(BizProtocolPaymentTransfer).count() == 0
  449. def test_out_of_scope_write_is_checked_before_any_mutation():
  450. from app.services.market_payment import update_transfer
  451. db = _session()
  452. _payment_tree(db, 'other', sales='u2', transfer_statuses=('dfj',))
  453. before = db.get(BizProtocolPaymentTransfer, 't-other-0').remark
  454. with pytest.raises(HTTPException):
  455. update_transfer(
  456. db,
  457. transfer_id='t-other-0',
  458. fields={'remark': 'must-not-write'},
  459. ctx=_ctx(),
  460. user_info=_user(),
  461. )
  462. db.expire_all()
  463. assert db.get(BizProtocolPaymentTransfer, 't-other-0').remark == before
  464. @pytest.mark.parametrize('protocol_status', ['yzf', 'sdfq', 'zky'])
  465. def test_terminal_protocol_rejects_direct_transfer_create_without_mutation(protocol_status):
  466. from app.services.market_payment import create_transfer
  467. db = _session()
  468. _, _, schedule = _payment_tree(
  469. db, f'terminal-create-{protocol_status}', protocol_status=protocol_status
  470. )
  471. with pytest.raises(HTTPException):
  472. create_transfer(
  473. db,
  474. schedule_id=schedule.id,
  475. fields={'transferAmount': 10, 'transferType': 'bank'},
  476. ctx=_ctx(),
  477. user_info=_user(),
  478. )
  479. assert db.query(BizProtocolPaymentTransfer).count() == 0
  480. @pytest.mark.parametrize('schedule_status', ['cleared', 'void'])
  481. def test_non_open_schedule_rejects_direct_transfer_create_without_mutation(schedule_status):
  482. from app.services.market_payment import create_transfer
  483. db = _session()
  484. _, _, schedule = _payment_tree(
  485. db, f'closed-create-{schedule_status}', schedule_status=schedule_status
  486. )
  487. with pytest.raises(HTTPException):
  488. create_transfer(
  489. db,
  490. schedule_id=schedule.id,
  491. fields={'transferAmount': 10, 'transferType': 'bank'},
  492. ctx=_ctx(),
  493. user_info=_user(),
  494. )
  495. assert db.query(BizProtocolPaymentTransfer).count() == 0
  496. @pytest.mark.parametrize(
  497. ('protocol_status', 'schedule_status', 'pay_status', 'operation'),
  498. [
  499. ('yzf', 'open', 'dfj', 'update'),
  500. ('sdfq', 'open', 'dfj', 'submit'),
  501. ('zky', 'open', 'jfqrz', 'withdraw'),
  502. ('sxz', 'cleared', 'dfj', 'update'),
  503. ('sxz', 'void', 'dfj', 'submit'),
  504. ],
  505. )
  506. def test_ineligible_parent_rejects_direct_transfer_mutations_without_mutation(
  507. protocol_status, schedule_status, pay_status, operation
  508. ):
  509. from app.services.market_payment import submit_transfer, update_transfer, withdraw_transfer
  510. db = _session()
  511. key = f'ineligible-{protocol_status}-{schedule_status}-{operation}'
  512. _payment_tree(
  513. db,
  514. key,
  515. protocol_status=protocol_status,
  516. schedule_status=schedule_status,
  517. transfer_statuses=(pay_status,),
  518. )
  519. transfer_id = f't-{key}-0'
  520. before = db.get(BizProtocolPaymentTransfer, transfer_id)
  521. before_values = (before.pay_status, before.remark, before.update_by, before.update_time)
  522. with pytest.raises(HTTPException):
  523. if operation == 'update':
  524. update_transfer(
  525. db,
  526. transfer_id=transfer_id,
  527. fields={'remark': 'must-not-write'},
  528. ctx=_ctx(),
  529. user_info=_user(),
  530. )
  531. elif operation == 'submit':
  532. submit_transfer(db, transfer_id=transfer_id, ctx=_ctx(), user_info=_user())
  533. else:
  534. withdraw_transfer(db, transfer_id=transfer_id, ctx=_ctx(), user_info=_user())
  535. db.expire_all()
  536. after = db.get(BizProtocolPaymentTransfer, transfer_id)
  537. assert (after.pay_status, after.remark, after.update_by, after.update_time) == before_values
  538. def test_chase_excludes_all_terminal_protocols_and_non_open_schedules():
  539. from app.services.market_payment import list_payment_chase
  540. db = _session()
  541. _payment_tree(db, 'eligible-chase')
  542. for status in ('yzf', 'sdfq', 'zky'):
  543. _payment_tree(db, f'terminal-chase-{status}', protocol_status=status)
  544. _payment_tree(db, 'cleared-chase', schedule_status='cleared')
  545. _payment_tree(db, 'void-chase', schedule_status='void')
  546. items, total = list_payment_chase(db, ctx=_ctx(), user_info=_user())
  547. assert total == 1
  548. assert [item['scheduleId'] for item in items] == ['sch-eligible-chase']
  549. def test_yjf_is_immutable_and_double_submit_fails_closed():
  550. from app.services.market_payment import submit_transfer, update_transfer, withdraw_transfer
  551. db = _session()
  552. _payment_tree(db, 'draft', transfer_statuses=('dfj',))
  553. submit_transfer(db, transfer_id='t-draft-0', ctx=_ctx(), user_info=_user())
  554. with pytest.raises(ValueError, match='状态'):
  555. submit_transfer(db, transfer_id='t-draft-0', ctx=_ctx(), user_info=_user())
  556. _payment_tree(db, 'confirmed', transfer_statuses=('yjf',))
  557. with pytest.raises(ValueError):
  558. update_transfer(
  559. db,
  560. transfer_id='t-confirmed-0',
  561. fields={'remark': 'no'},
  562. ctx=_ctx(),
  563. user_info=_user(),
  564. )
  565. with pytest.raises(ValueError):
  566. withdraw_transfer(
  567. db,
  568. transfer_id='t-confirmed-0',
  569. ctx=_ctx(),
  570. user_info=_user(),
  571. )
  572. def test_transfer_dto_forbids_authorization_status_and_finance_fields():
  573. from app.schemas.market_mobile import MarketPaymentTransferBody
  574. forbidden = (
  575. {'tenantNo': 'tenant-b'},
  576. {'scope': 'manager'},
  577. {'payStatus': 'yjf'},
  578. {'financeRemark': 'confirmed'},
  579. {'scheduleStatus': 'void'},
  580. )
  581. for extra in forbidden:
  582. with pytest.raises(ValidationError):
  583. MarketPaymentTransferBody.model_validate(
  584. {'transferAmount': 10, 'transferType': 'bank', **extra}
  585. )
  586. @pytest.mark.parametrize(
  587. 'amount',
  588. [float('inf'), float('-inf'), float('nan'), '0.001', '10000000000000000.00'],
  589. )
  590. def test_transfer_amount_rejects_non_finite_over_precision_and_overflow(amount):
  591. from app.schemas.market_mobile import MarketPaymentTransferBody
  592. with pytest.raises(ValidationError):
  593. MarketPaymentTransferBody.model_validate({'transferAmount': amount})
  594. @pytest.mark.parametrize('amount', ['0.01', '9999999999999999.99'])
  595. def test_transfer_amount_accepts_decimal_18_2_boundaries_exactly(amount):
  596. from app.schemas.market_mobile import MarketPaymentTransferBody
  597. body = MarketPaymentTransferBody.model_validate({'transferAmount': amount})
  598. assert body.transfer_amount == Decimal(amount)
  599. def test_task5_money_is_fixed_two_decimal_string_in_actual_json_response():
  600. from app.core.response import success_response
  601. from app.schemas.market_mobile import MarketPaymentTransferCreateBody
  602. from app.services.market_payment import _payment_dict, create_transfer
  603. db = _session()
  604. _, _, schedule = _payment_tree(
  605. db, 'money-json', due_amount=Decimal('9999999999999999.99')
  606. )
  607. body = MarketPaymentTransferCreateBody.model_validate(
  608. {
  609. 'scheduleId': schedule.id,
  610. 'transferAmount': '9999999999999999.99',
  611. 'transferType': 'bank',
  612. }
  613. )
  614. payload = body.model_dump(by_alias=True, exclude_unset=True)
  615. schedule_id = payload.pop('scheduleId')
  616. transfer = create_transfer(
  617. db,
  618. schedule_id=schedule_id,
  619. fields=payload,
  620. ctx=_ctx(),
  621. user_info=_user(),
  622. )
  623. max_money = Decimal('9999999999999999.99')
  624. payment = _payment_dict(
  625. SimpleNamespace(
  626. id='sp-json',
  627. sample_code='S-json',
  628. pay_mode='fq',
  629. contract_amount=max_money,
  630. remark=None,
  631. ),
  632. SimpleNamespace(id='p-json', protocol_no='P-json', protocol_status='sxz'),
  633. None,
  634. [
  635. SimpleNamespace(
  636. id='sch-json',
  637. schedule_no=1,
  638. due_amount=max_money,
  639. due_date='20261011',
  640. schedule_status='open',
  641. )
  642. ],
  643. {},
  644. )
  645. encoded = jsonable_encoder(
  646. success_response({'transfer': transfer, 'payment': payment})
  647. )
  648. response = JSONResponse(encoded)
  649. decoded = response.body.decode('utf-8')
  650. assert transfer['transferAmount'] == '9999999999999999.99'
  651. assert payment['contractAmount'] == '9999999999999999.99'
  652. assert payment['schedules'][0]['dueAmount'] == '9999999999999999.99'
  653. assert '"9999999999999999.99"' in decoded
  654. def test_task5_normal_money_is_always_serialized_with_two_decimals():
  655. from app.services.market_payment import list_payment_chase
  656. db = _session()
  657. _payment_tree(db, 'normal-money', due_amount=Decimal('100.00'))
  658. items, _ = list_payment_chase(db, ctx=_ctx(), user_info=_user())
  659. assert items[0]['dueAmount'] == '100.00'
  660. assert items[0]['paidAmount'] == '0.00'
  661. assert items[0]['arrearsAmount'] == '100.00'
  662. def test_market_routes_expose_only_allowed_payment_mutations():
  663. from app.api.market_mobile import router
  664. paths = {route.path for route in router.routes}
  665. for scope in ('specialist', 'manager'):
  666. prefix = f'/mini/market/{scope}'
  667. assert f'{prefix}/payment-chase' in paths
  668. assert f'{prefix}/payment-chases' not in paths
  669. assert f'{prefix}/payments' in paths
  670. assert f'{prefix}/payment-transfers' in paths
  671. assert f'{prefix}/payment-transfers/{{transfer_id}}/submit' in paths
  672. assert f'{prefix}/payment-transfers/{{transfer_id}}/withdraw' in paths
  673. assert f'{prefix}/payment-transfers/{{transfer_id}}/confirm' not in paths
  674. assert f'{prefix}/payment-transfers/{{transfer_id}}/reject' not in paths
  675. assert f'{prefix}/payment-transfers/{{transfer_id}}/refund' not in paths
  676. assert f'{prefix}/payment-transfers/{{transfer_id}}/invoice' not in paths
  677. def test_list_queries_batch_children_without_n_plus_one():
  678. from app.services.market_payment import list_payments
  679. db = _session()
  680. for index in range(8):
  681. _payment_tree(db, f'row-{index}', transfer_statuses=('dfj', 'yjf'))
  682. statements = []
  683. def capture(_conn, _cursor, statement, _parameters, _context, _many):
  684. statements.append(statement)
  685. event.listen(db.get_bind(), 'before_cursor_execute', capture)
  686. try:
  687. items, total = list_payments(
  688. db, ctx=_ctx(), user_info=_user(), page=1, page_size=5
  689. )
  690. finally:
  691. event.remove(db.get_bind(), 'before_cursor_execute', capture)
  692. assert total == 8
  693. assert len(items) == 5
  694. assert len(statements) <= 5
  695. def test_payment_list_contains_card_summary_totals():
  696. from app.services.market_payment import list_payments
  697. db = _session()
  698. _payment_tree(db, 'summary', due_amount=Decimal('100.00'), transfer_statuses=('yjf',))
  699. items, total = list_payments(db, ctx=_ctx(), user_info=_user())
  700. assert total == 1
  701. assert items[0]['paidAmount'] == '20.00'
  702. assert items[0]['remainAmount'] == '80.00'
  703. assert items[0]['activeScheduleCount'] == 1
  704. assert items[0]['clearedScheduleCount'] == 0
  705. assert items[0]['paymentProgress'] == '部分缴费'
  706. assert items[0]['signDate'] == '2026-10-01'
  707. assert items[0]['storageYears'] == 20
  708. assert items[0]['sampleTypeName'] == '样本类型-summary'
  709. assert items[0]['discountAmount'] == '20.00'
  710. assert items[0]['receivableAmount'] == '100.00'
  711. assert items[0]['dueRemainAmount'] == '80.00'
  712. assert len(items[0]['periods']) == 6
  713. assert items[0]['periods'][0]['scheduleNo'] == 1
  714. def test_payment_sample_meta_uses_tenant_and_sample_code_after_protocol_change():
  715. from app.services.market_payment import list_payments
  716. db = _session()
  717. _, payment, _ = _payment_tree(db, 'protocol-changed')
  718. sample = (
  719. db.query(BizProtocolSample)
  720. .filter(BizProtocolSample.sample_code == payment.sample_code)
  721. .one()
  722. )
  723. sample.collection_protocol_id = 'p-previous-protocol'
  724. db.commit()
  725. items, total = list_payments(db, ctx=_ctx(), user_info=_user())
  726. assert total == 1
  727. assert items[0]['storageYears'] == 20
  728. assert items[0]['sampleTypeName'] == '样本类型-protocol-changed'
  729. assert items[0]['discountAmount'] == '20.00'