test_market_payment_api.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575
  1. import asyncio
  2. from datetime import date, datetime, timedelta
  3. from decimal import Decimal
  4. from types import SimpleNamespace
  5. import pytest
  6. from fastapi import HTTPException
  7. from fastapi.encoders import jsonable_encoder
  8. from pydantic import ValidationError
  9. from sqlalchemy import create_engine, event
  10. from sqlalchemy.orm import sessionmaker
  11. from starlette.responses import JSONResponse
  12. from app.models.base import Base
  13. from app.models.models import (
  14. BizAttachment,
  15. BizCellSampleType,
  16. BizCollectionProtocol,
  17. BizCollectionRegionStaff,
  18. BizCollectionHospital,
  19. BizCollectionRegion,
  20. BizItemUnit,
  21. BizProtocolAudit,
  22. BizProtocolCollectionInfo,
  23. BizProtocolFullmoonTodo,
  24. BizProtocolPayment,
  25. BizProtocolPaymentDetail,
  26. BizProtocolPartyCustomer,
  27. BizProtocolPaymentSchedule,
  28. BizProtocolPaymentTransfer,
  29. BizProtocolSample,
  30. BizProtocolSamplePayment,
  31. BizProtocolSampleTransport,
  32. BizProtocolStorageCert,
  33. BizPresignOrder,
  34. BizPresignTransfer,
  35. BizSampleFreight,
  36. BizSampleFreightItem,
  37. BizSampleReception,
  38. SysDictData,
  39. SysPost,
  40. SysPostRole,
  41. SysRole,
  42. SysRoleDataScopeDetail,
  43. SysUser,
  44. SysUserPost,
  45. SysUserRole,
  46. )
  47. def _session():
  48. engine = create_engine('sqlite:///:memory:')
  49. Base.metadata.create_all(
  50. engine,
  51. tables=[
  52. BizCollectionProtocol.__table__,
  53. BizProtocolCollectionInfo.__table__,
  54. BizProtocolPartyCustomer.__table__,
  55. BizProtocolSamplePayment.__table__,
  56. BizProtocolPaymentSchedule.__table__,
  57. BizProtocolPaymentTransfer.__table__,
  58. BizProtocolPayment.__table__,
  59. BizProtocolPaymentDetail.__table__,
  60. BizProtocolSample.__table__,
  61. BizProtocolSampleTransport.__table__,
  62. BizProtocolAudit.__table__,
  63. BizProtocolStorageCert.__table__,
  64. BizProtocolFullmoonTodo.__table__,
  65. BizPresignOrder.__table__,
  66. BizPresignTransfer.__table__,
  67. BizSampleFreight.__table__,
  68. BizSampleFreightItem.__table__,
  69. BizSampleReception.__table__,
  70. BizCellSampleType.__table__,
  71. BizAttachment.__table__,
  72. BizCollectionRegionStaff.__table__,
  73. BizCollectionRegion.__table__,
  74. BizCollectionHospital.__table__,
  75. BizItemUnit.__table__,
  76. SysDictData.__table__,
  77. SysUser.__table__,
  78. SysRole.__table__,
  79. SysRoleDataScopeDetail.__table__,
  80. SysUserRole.__table__,
  81. SysPost.__table__,
  82. SysPostRole.__table__,
  83. SysUserPost.__table__,
  84. ],
  85. )
  86. return sessionmaker(bind=engine)()
  87. def _ctx(scope_kind='specialist', user_id='u1'):
  88. from app.services.market_field_scope import MarketFieldContext
  89. return MarketFieldContext('tenant-a', user_id, scope_kind)
  90. def _user(user_id='u1'):
  91. return {
  92. 'user_id': user_id,
  93. 'tenant_no': 'tenant-a',
  94. 'role': 'super_admin',
  95. 'region_id': 'dept-from-auth',
  96. }
  97. def _payment_tree(
  98. db,
  99. key,
  100. *,
  101. sales='u1',
  102. manager='manager-a',
  103. region='region-a',
  104. tenant='tenant-a',
  105. protocol_status='sxz',
  106. payment_model_version=2,
  107. due_date=None,
  108. due_amount=100,
  109. schedule_status='open',
  110. deleted=0,
  111. transfer_statuses=(),
  112. transfer_dept='',
  113. register_date='20261001',
  114. storage_years=20,
  115. sample_price=80,
  116. sample_original_price=100,
  117. ):
  118. protocol = BizCollectionProtocol(
  119. id=f'p-{key}',
  120. protocol_no=f'P-{key}',
  121. protocol_status=protocol_status,
  122. payment_model_version=payment_model_version,
  123. register_date=register_date,
  124. tenant_no=tenant,
  125. create_dept_id='dept-a',
  126. create_time=datetime.now(),
  127. del_flag=deleted,
  128. )
  129. collection = BizProtocolCollectionInfo(
  130. id=f'ci-{key}',
  131. collection_protocol_id=protocol.id,
  132. region_id=region,
  133. sales_user_id=sales,
  134. region_manager_user_id=manager,
  135. tenant_no=tenant,
  136. create_dept_id='dept-a',
  137. del_flag=deleted,
  138. )
  139. party = BizProtocolPartyCustomer(
  140. id=f'party-{key}',
  141. collection_protocol_id=protocol.id,
  142. mother_name=f'客户-{key}',
  143. tenant_no=tenant,
  144. create_dept_id='dept-a',
  145. del_flag=deleted,
  146. )
  147. payment = BizProtocolSamplePayment(
  148. id=f'sp-{key}',
  149. collection_protocol_id=protocol.id,
  150. sample_code=f'S-{key}',
  151. pay_mode='fq',
  152. contract_amount=due_amount,
  153. tenant_no=tenant,
  154. create_dept_id='dept-a',
  155. create_time=datetime.now(),
  156. del_flag=deleted,
  157. )
  158. schedule = BizProtocolPaymentSchedule(
  159. id=f'sch-{key}',
  160. sample_payment_id=payment.id,
  161. collection_protocol_id=protocol.id,
  162. sample_code=payment.sample_code,
  163. schedule_no=1,
  164. due_amount=due_amount,
  165. due_date=due_date or date.today().strftime('%Y%m%d'),
  166. schedule_status=schedule_status,
  167. tenant_no=tenant,
  168. create_dept_id='dept-a',
  169. del_flag=deleted,
  170. )
  171. sample_type = BizCellSampleType(
  172. id=f'type-{key}',
  173. type_code=f'TYPE-{key}',
  174. type_name=f'样本类型-{key}',
  175. tenant_no=tenant,
  176. create_dept_id='dept-a',
  177. del_flag=deleted,
  178. )
  179. sample = BizProtocolSample(
  180. id=f'sample-{key}',
  181. collection_protocol_id=protocol.id,
  182. sample_type_id=sample_type.id,
  183. sample_code=payment.sample_code,
  184. storage_years=storage_years,
  185. price=sample_price,
  186. original_price=sample_original_price,
  187. tenant_no=tenant,
  188. create_dept_id='dept-a',
  189. del_flag=deleted,
  190. )
  191. db.add_all([protocol, collection, party, payment, schedule, sample_type, sample])
  192. for index, status in enumerate(transfer_statuses):
  193. db.add(
  194. BizProtocolPaymentTransfer(
  195. id=f't-{key}-{index}',
  196. schedule_id=schedule.id,
  197. sample_payment_id=payment.id,
  198. collection_protocol_id=protocol.id,
  199. sample_code=payment.sample_code,
  200. transfer_amount=20,
  201. transfer_time=datetime.now(),
  202. transfer_type='bank',
  203. pay_status=status,
  204. tenant_no=tenant,
  205. create_dept_id=transfer_dept,
  206. del_flag=deleted,
  207. )
  208. )
  209. db.commit()
  210. return protocol, payment, schedule
  211. def test_chase_is_overdue_union_next_30_days_with_parent_scope():
  212. from app.services.market_payment import list_payment_chase
  213. db = _session()
  214. today = date(2026, 10, 11)
  215. _payment_tree(db, 'overdue', due_date=(today - timedelta(days=1)).strftime('%Y%m%d'))
  216. _payment_tree(db, 'today', due_date=today.strftime('%Y%m%d'))
  217. _payment_tree(db, 'day30', due_date=(today + timedelta(days=30)).strftime('%Y%m%d'))
  218. _payment_tree(db, 'day31', due_date=(today + timedelta(days=31)).strftime('%Y%m%d'))
  219. _payment_tree(db, 'other', sales='u2', due_date=today.strftime('%Y%m%d'))
  220. _payment_tree(db, 'void', protocol_status='yzf', due_date=today.strftime('%Y%m%d'))
  221. _payment_tree(db, 'bad-date', due_date='not-a-date')
  222. items, total = list_payment_chase(
  223. db, ctx=_ctx(), user_info=_user(), today=today
  224. )
  225. assert total == 4
  226. assert {item['scheduleId'] for item in items} == {
  227. 'sch-overdue',
  228. 'sch-today',
  229. 'sch-day30',
  230. 'sch-void',
  231. }
  232. def test_payment_chase_uses_historical_nickname_without_user_no_fallback():
  233. from app.services.market_payment import list_payment_chase
  234. db = _session()
  235. db.add_all(
  236. [
  237. SysUser(id='u1', user_no='deleted-sales-no', nick_name='历史删除专员', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1),
  238. SysUser(id='blank-sales', user_no='raw-sales-no', nick_name='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1),
  239. ]
  240. )
  241. _payment_tree(db, 'historical-sales', sales='u1')
  242. _payment_tree(db, 'blank-sales', sales='blank-sales')
  243. historical_items, historical_total = list_payment_chase(
  244. db, ctx=_ctx(user_id='u1'), user_info=_user('u1')
  245. )
  246. blank_items, blank_total = list_payment_chase(
  247. db,
  248. ctx=_ctx(user_id='blank-sales'),
  249. user_info=_user('blank-sales'),
  250. )
  251. assert historical_total == 1
  252. assert historical_items[0]['salesUserName'] == '历史删除专员'
  253. assert blank_total == 1
  254. assert blank_items[0]['salesUserName'] == ''
  255. def test_payment_list_filters_progress_from_schedule_status_and_due_date_range():
  256. from app.services.market_payment import list_payments
  257. db = _session()
  258. _payment_tree(db, 'unpaid', due_date='20261010', due_amount=100)
  259. _payment_tree(
  260. db,
  261. 'partial',
  262. due_date='20261011',
  263. due_amount=100,
  264. transfer_statuses=('yjf',),
  265. )
  266. _payment_tree(
  267. db,
  268. 'paid',
  269. due_date='20261012',
  270. due_amount=20,
  271. schedule_status='cleared',
  272. transfer_statuses=('yjf',),
  273. )
  274. _payment_tree(db, 'outside', due_date='20261101', due_amount=100)
  275. partial, partial_total = list_payments(
  276. db,
  277. ctx=_ctx(),
  278. user_info=_user(),
  279. progress_status='bfyj',
  280. installment_due_date_start='2026-10-01',
  281. installment_due_date_end='2026-10-31',
  282. )
  283. paid, paid_total = list_payments(
  284. db,
  285. ctx=_ctx(),
  286. user_info=_user(),
  287. progress_status='yjq',
  288. installment_due_date_start='2026-10-01',
  289. installment_due_date_end='2026-10-31',
  290. )
  291. assert partial_total == 1
  292. assert [item['id'] for item in partial] == ['sp-partial']
  293. assert paid_total == 1
  294. assert [item['id'] for item in paid] == ['sp-paid']
  295. def test_payment_progress_infers_cleared_when_confirmed_paid_covers_due():
  296. from app.services.market_payment import list_payments
  297. db = _session()
  298. _payment_tree(
  299. db,
  300. 'amount-covered-but-open',
  301. due_amount=20,
  302. schedule_status='open',
  303. transfer_statuses=('yjf',),
  304. )
  305. _payment_tree(
  306. db,
  307. 'status-cleared-without-transfer',
  308. due_amount=100,
  309. schedule_status='cleared',
  310. )
  311. _payment_tree(
  312. db,
  313. 'partial-open',
  314. due_amount=100,
  315. schedule_status='open',
  316. transfer_statuses=('yjf',),
  317. )
  318. partial, partial_total = list_payments(
  319. db, ctx=_ctx(), user_info=_user(), progress_status='bfyj'
  320. )
  321. cleared, cleared_total = list_payments(
  322. db, ctx=_ctx(), user_info=_user(), progress_status='yjq'
  323. )
  324. assert partial_total == 1
  325. assert partial[0]['id'] == 'sp-partial-open'
  326. assert partial[0]['paymentProgress'] == '部分已缴'
  327. assert partial[0]['clearedScheduleCount'] == 0
  328. assert cleared_total == 2
  329. assert {item['id'] for item in cleared} == {
  330. 'sp-amount-covered-but-open',
  331. 'sp-status-cleared-without-transfer',
  332. }
  333. covered = next(item for item in cleared if item['id'] == 'sp-amount-covered-but-open')
  334. assert covered['paymentProgress'] == '已缴清'
  335. assert covered['clearedScheduleCount'] == 1
  336. assert covered['schedules'][0]['scheduleStatus'] == 'cleared'
  337. def test_progress_filter_pages_in_sql_before_loading_children(monkeypatch):
  338. from app.services import market_payment
  339. db = _session()
  340. for key in ('partial-a', 'partial-b', 'partial-c'):
  341. _payment_tree(
  342. db,
  343. key,
  344. due_amount=100,
  345. transfer_statuses=('yjf',),
  346. )
  347. calls = []
  348. original = market_payment._load_children
  349. def tracked_load_children(db_arg, payment_ids, tenant_no):
  350. calls.append(list(payment_ids))
  351. return original(db_arg, payment_ids, tenant_no)
  352. monkeypatch.setattr(market_payment, '_load_children', tracked_load_children)
  353. items, total = market_payment.list_payments(
  354. db,
  355. ctx=_ctx(),
  356. user_info=_user(),
  357. progress_status='bfyj',
  358. page=2,
  359. page_size=1,
  360. )
  361. assert total == 3
  362. assert len(items) == 1
  363. assert calls == [[items[0]['id']]]
  364. def test_progress_counts_confirmed_transfers_from_void_schedules_like_web():
  365. from app.services.market_payment import list_payments
  366. db = _session()
  367. _payment_tree(db, 'void-unpaid', schedule_status='void', due_amount=100)
  368. _payment_tree(
  369. db,
  370. 'void-confirmed',
  371. schedule_status='void',
  372. due_amount=20,
  373. transfer_statuses=('yjf',),
  374. )
  375. unpaid, unpaid_total = list_payments(
  376. db,
  377. ctx=_ctx(),
  378. user_info=_user(),
  379. progress_status='dfj',
  380. )
  381. partial, partial_total = list_payments(
  382. db,
  383. ctx=_ctx(),
  384. user_info=_user(),
  385. progress_status='bfyj',
  386. )
  387. cleared, cleared_total = list_payments(
  388. db,
  389. ctx=_ctx(),
  390. user_info=_user(),
  391. progress_status='yjq',
  392. )
  393. assert unpaid_total == 1
  394. assert [item['id'] for item in unpaid] == ['sp-void-unpaid']
  395. assert unpaid[0]['progressStatus'] == 'dfj'
  396. assert unpaid[0]['paymentProgress'] == '待缴费'
  397. assert unpaid[0]['paidAmount'] == '0.00'
  398. assert unpaid[0]['activeScheduleCount'] == 0
  399. assert partial_total == 1
  400. assert [item['id'] for item in partial] == ['sp-void-confirmed']
  401. assert partial[0]['progressStatus'] == 'bfyj'
  402. assert partial[0]['paymentProgress'] == '部分已缴'
  403. assert partial[0]['paidAmount'] == '20.00'
  404. assert partial[0]['activeScheduleCount'] == 0
  405. assert cleared_total == 0
  406. assert cleared == []
  407. def test_chase_counts_only_yjf_and_excludes_fully_confirmed():
  408. from app.services.market_payment import list_payment_chase
  409. db = _session()
  410. _payment_tree(
  411. db,
  412. 'part-paid',
  413. due_amount=100,
  414. transfer_statuses=('dfj', 'jfqrz', 'yjf'),
  415. )
  416. _payment_tree(
  417. db,
  418. 'fully-paid',
  419. due_amount=60,
  420. transfer_statuses=('yjf', 'yjf', 'yjf'),
  421. )
  422. items, total = list_payment_chase(db, ctx=_ctx(), user_info=_user())
  423. assert total == 1
  424. assert items[0]['scheduleId'] == 'sch-part-paid'
  425. assert items[0]['paidAmount'] == '20.00'
  426. assert items[0]['arrearsAmount'] == '80.00'
  427. def test_chase_bound_payment_detail_shows_only_requested_schedule():
  428. from app.services.market_payment import get_payment_chase_detail
  429. db = _session()
  430. _, payment, first_schedule = _payment_tree(
  431. db,
  432. 'chase-detail',
  433. due_amount=100,
  434. transfer_statuses=('yjf',),
  435. )
  436. second_schedule = BizProtocolPaymentSchedule(
  437. id='sch-chase-detail-2',
  438. sample_payment_id=payment.id,
  439. collection_protocol_id='p-chase-detail',
  440. sample_code=payment.sample_code,
  441. schedule_no=2,
  442. due_amount=50,
  443. due_date=date.today().strftime('%Y%m%d'),
  444. schedule_status='cleared',
  445. tenant_no='tenant-a',
  446. create_dept_id='dept-a',
  447. del_flag=0,
  448. )
  449. second_transfer = BizProtocolPaymentTransfer(
  450. id='t-chase-detail-2',
  451. schedule_id=second_schedule.id,
  452. sample_payment_id=payment.id,
  453. collection_protocol_id='p-chase-detail',
  454. sample_code=payment.sample_code,
  455. transfer_amount=50,
  456. transfer_time=datetime.now(),
  457. transfer_type='bank',
  458. pay_status='yjf',
  459. tenant_no='tenant-a',
  460. create_dept_id='dept-a',
  461. del_flag=0,
  462. )
  463. db.add_all([second_schedule, second_transfer])
  464. db.commit()
  465. detail = get_payment_chase_detail(
  466. db,
  467. schedule_id=first_schedule.id,
  468. view='payment',
  469. ctx=_ctx(),
  470. user_info=_user(),
  471. )
  472. assert detail['chaseView'] == 'payment'
  473. assert detail['targetScheduleId'] == first_schedule.id
  474. assert [row['id'] for row in detail['schedules']] == [first_schedule.id]
  475. assert detail['receivableAmount'] == '100.00'
  476. assert detail['paidAmount'] == '20.00'
  477. assert detail['dueRemainAmount'] == '80.00'
  478. assert detail['remainAmount'] == '80.00'
  479. assert detail['clearedScheduleCount'] == 0
  480. assert detail['activeScheduleCount'] == 1
  481. assert detail['progressStatus'] == 'bfyj'
  482. assert detail['paymentProgress'] == '部分已缴'
  483. assert [
  484. period['scheduleId']
  485. for period in detail['periods']
  486. if period is not None
  487. ] == [first_schedule.id]
  488. assert detail['allowedActions'] == []
  489. def test_chase_bound_protocol_detail_is_read_only(monkeypatch):
  490. from app.services.market_payment import get_payment_chase_detail
  491. from app.services import market_protocol
  492. db = _session()
  493. _, _, schedule = _payment_tree(db, 'chase-protocol')
  494. resolved_parents = []
  495. def fake_cell_detail(db_arg, protocol, tenant_no):
  496. resolved_parents.append((db_arg, protocol.id, tenant_no))
  497. return {
  498. 'source': 'cell_storage',
  499. 'basic': {'protocolNo': protocol.protocol_no},
  500. 'collection': None,
  501. 'customer': None,
  502. 'samples': [],
  503. 'payment': None,
  504. 'paymentModelVersion': protocol.payment_model_version,
  505. 'samplePayments': [],
  506. 'paymentDetails': [],
  507. 'depositTransfers': [],
  508. 'presignDepositHint': None,
  509. 'storageCert': None,
  510. 'fullmoonTodo': None,
  511. 'audits': [],
  512. 'attachments': {},
  513. }
  514. monkeypatch.setattr(market_protocol, '_cell_detail', fake_cell_detail)
  515. detail = get_payment_chase_detail(
  516. db,
  517. schedule_id=schedule.id,
  518. view='protocol',
  519. ctx=_ctx(),
  520. user_info=_user(),
  521. )
  522. assert detail['chaseView'] == 'protocol'
  523. assert detail['targetScheduleId'] == schedule.id
  524. assert detail['source'] == 'cell_storage'
  525. assert detail['basic']['protocolNo'] == 'P-chase-protocol'
  526. assert detail['allowedActions'] == []
  527. assert resolved_parents == [(db, 'p-chase-protocol', 'tenant-a')]
  528. @pytest.mark.parametrize(
  529. ('key', 'tree_kwargs', 'ctx', 'user'),
  530. [
  531. ('outside-scope', {'sales': 'u2'}, _ctx(), _user()),
  532. ('cross-tenant', {'tenant': 'tenant-b'}, _ctx(), _user()),
  533. ('void-schedule', {'schedule_status': 'void'}, _ctx(), _user()),
  534. ('invalid-due-date', {'due_date': '20260230'}, _ctx(), _user()),
  535. (
  536. 'outside-chase-window',
  537. {'due_date': (date.today() + timedelta(days=31)).strftime('%Y%m%d')},
  538. _ctx(),
  539. _user(),
  540. ),
  541. ('no-arrears', {'transfer_statuses': ('yjf',) * 5}, _ctx(), _user()),
  542. ],
  543. )
  544. def test_chase_bound_detail_rejects_guessed_non_candidate_schedule(
  545. key, tree_kwargs, ctx, user
  546. ):
  547. from app.services.market_payment import get_payment_chase_detail
  548. db = _session()
  549. _, _, schedule = _payment_tree(db, key, **tree_kwargs)
  550. with pytest.raises(HTTPException) as exc:
  551. get_payment_chase_detail(
  552. db,
  553. schedule_id=schedule.id,
  554. view='payment',
  555. ctx=ctx,
  556. user_info=user,
  557. )
  558. assert exc.value.status_code == 404
  559. def test_chase_filters_counts_orders_and_pages_in_sql_before_materialization():
  560. from app.services.market_payment import list_payment_chase
  561. db = _session()
  562. today = date(2026, 10, 11)
  563. for index in range(35):
  564. _payment_tree(
  565. db,
  566. f'history-{index:02d}',
  567. due_date=(today - timedelta(days=index + 1)).strftime('%Y%m%d'),
  568. transfer_statuses=('dfj',),
  569. )
  570. _payment_tree(db, 'invalid-day', due_date='20260230')
  571. statements = []
  572. def capture(_conn, _cursor, statement, _parameters, _context, _many):
  573. statements.append(statement)
  574. event.listen(db.get_bind(), 'before_cursor_execute', capture)
  575. try:
  576. items, total = list_payment_chase(
  577. db,
  578. ctx=_ctx(),
  579. user_info=_user(),
  580. today=today,
  581. page=3,
  582. page_size=5,
  583. )
  584. finally:
  585. event.remove(db.get_bind(), 'before_cursor_execute', capture)
  586. assert total == 35
  587. assert len(items) == 5
  588. assert [item['scheduleId'] for item in items] == [
  589. 'sch-history-24',
  590. 'sch-history-23',
  591. 'sch-history-22',
  592. 'sch-history-21',
  593. 'sch-history-20',
  594. ]
  595. chase_selects = [sql for sql in statements if 'biz_protocol_payment_schedule' in sql]
  596. assert len(chase_selects) == 2
  597. assert ' LIMIT ' in chase_selects[-1].upper()
  598. assert 'SUM(CASE' in chase_selects[-1].upper()
  599. assert not any(
  600. 'FROM biz_protocol_payment_transfer' in sql and 'SUM(CASE' not in sql.upper()
  601. for sql in statements
  602. )
  603. def test_payment_list_includes_legacy_models_and_uses_parent_scope_not_transfer_department():
  604. from app.services.market_payment import list_payments
  605. db = _session()
  606. _payment_tree(db, 'v2', transfer_statuses=('dfj',), transfer_dept='')
  607. _payment_tree(db, 'v1', payment_model_version=1)
  608. _payment_tree(db, 'other', sales='u2')
  609. _payment_tree(db, 'cross', tenant='tenant-b')
  610. _payment_tree(db, 'deleted', deleted=1)
  611. items, total = list_payments(db, ctx=_ctx(), user_info=_user())
  612. assert total == 2
  613. assert {item['id'] for item in items} == {'sp-v2', 'sp-v1'}
  614. assert {item['paymentModelVersion'] for item in items} == {1, 2}
  615. def test_manager_scope_is_fixed_by_route_context_and_client_filter_cannot_expand():
  616. from app.services.market_payment import list_payment_chase
  617. db = _session()
  618. _payment_tree(db, 'manager', manager='manager-a', sales='u2')
  619. _payment_tree(db, 'outside', manager='manager-b', sales='u1')
  620. items, total = list_payment_chase(
  621. db,
  622. ctx=_ctx('manager', 'manager-a'),
  623. user_info=_user('manager-a'),
  624. sales_user_id='u1',
  625. )
  626. assert total == 0
  627. assert items == []
  628. def test_payment_detail_rejects_out_of_scope_sample():
  629. from app.services.market_payment import get_payment_detail
  630. db = _session()
  631. _payment_tree(db, 'other', sales='u2')
  632. with pytest.raises(HTTPException) as exc:
  633. get_payment_detail(
  634. db, payment_id='sp-other', ctx=_ctx(), user_info=_user()
  635. )
  636. assert exc.value.status_code == 404
  637. def test_payment_detail_reads_legacy_model_like_web():
  638. from app.services.market_payment import get_payment_detail
  639. db = _session()
  640. _payment_tree(db, 'legacy', payment_model_version=1)
  641. detail = get_payment_detail(
  642. db, payment_id='sp-legacy', ctx=_ctx(), user_info=_user()
  643. )
  644. assert detail['id'] == 'sp-legacy'
  645. assert detail['paymentModelVersion'] == 1
  646. def test_market_can_save_submit_and_withdraw_transfer_atomically():
  647. from app.services.market_payment import (
  648. create_transfer,
  649. submit_transfer,
  650. update_transfer,
  651. withdraw_transfer,
  652. )
  653. db = _session()
  654. _, _, schedule = _payment_tree(db, 'own')
  655. db.add(
  656. BizAttachment(
  657. id='att-pay',
  658. business_type='market:specialist:payment:staging',
  659. original_name='a.jpg',
  660. file_ext='jpg',
  661. file_url='a.jpg',
  662. file_size=10,
  663. file_type='image/jpeg',
  664. tenant_no='tenant-a',
  665. create_by='u1',
  666. create_dept_id='',
  667. del_flag=0,
  668. )
  669. )
  670. db.commit()
  671. created = create_transfer(
  672. db,
  673. schedule_id=schedule.id,
  674. fields={
  675. 'transferAmount': 50,
  676. 'transferTime': '2026-10-11 10:00:00',
  677. 'transferType': 'bank',
  678. 'transferAttachments': 'a.jpg',
  679. },
  680. ctx=_ctx(),
  681. user_info=_user(),
  682. )
  683. assert created['payStatus'] == 'dfj'
  684. row = db.get(BizProtocolPaymentTransfer, created['id'])
  685. assert row.create_dept_id == 'dept-from-auth'
  686. attachment = db.get(BizAttachment, 'att-pay')
  687. assert (attachment.business_type, attachment.business_id) == (
  688. 'market:specialist:payment:bound',
  689. created['id'],
  690. )
  691. updated = update_transfer(
  692. db,
  693. transfer_id=created['id'],
  694. fields={'remark': '补充说明'},
  695. ctx=_ctx(),
  696. user_info=_user(),
  697. )
  698. assert updated['remark'] == '补充说明'
  699. assert submit_transfer(
  700. db, transfer_id=created['id'], ctx=_ctx(), user_info=_user()
  701. )['payStatus'] == 'jfqrz'
  702. assert withdraw_transfer(
  703. db, transfer_id=created['id'], ctx=_ctx(), user_info=_user()
  704. )['payStatus'] == 'dfj'
  705. @pytest.mark.parametrize('operation', ['confirm', 'reject', 'withdraw'])
  706. def test_web_finance_transitions_lock_schedule_before_transfer(
  707. monkeypatch, operation
  708. ):
  709. from sqlalchemy.orm import Query
  710. from app.crud import protocol_sample_payment as payment_crud
  711. db = _session()
  712. _payment_tree(db, f'finance-lock-{operation}', transfer_statuses=('jfqrz',))
  713. transfer_id = f't-finance-lock-{operation}-0'
  714. locked_entities = []
  715. original = Query.with_for_update
  716. def record_lock(query, *args, **kwargs):
  717. entity = query.column_descriptions[0].get('entity')
  718. if entity in {BizProtocolPaymentSchedule, BizProtocolPaymentTransfer}:
  719. locked_entities.append(entity)
  720. return original(query, *args, **kwargs)
  721. monkeypatch.setattr(Query, 'with_for_update', record_lock)
  722. function = {
  723. 'confirm': payment_crud.confirm_receipt,
  724. 'reject': payment_crud.reject_receipt,
  725. 'withdraw': payment_crud.withdraw_receipt,
  726. }[operation]
  727. function(
  728. db,
  729. transfer_id=transfer_id,
  730. tenant_no='tenant-a',
  731. user_id='finance-user',
  732. )
  733. assert locked_entities == [
  734. BizProtocolPaymentSchedule,
  735. BizProtocolPaymentTransfer,
  736. ]
  737. @pytest.mark.parametrize(
  738. ('operation', 'initial_status'),
  739. [
  740. ('update', 'dfj'),
  741. ('submit', 'dfj'),
  742. ('withdraw', 'jfqrz'),
  743. ('confirm', 'jfqrz'),
  744. ('reject', 'jfqrz'),
  745. ('cross-tenant', 'dfj'),
  746. ],
  747. )
  748. def test_web_payment_transfer_writes_reject_out_of_scope_before_commit(
  749. operation, initial_status
  750. ):
  751. from app.api import protocol_sample_payment as payment_api
  752. db = _session()
  753. engine = db.get_bind()
  754. protocol, _, _ = _payment_tree(
  755. db,
  756. f'web-scope-{operation}',
  757. tenant='tenant-b' if operation == 'cross-tenant' else 'tenant-a',
  758. transfer_statuses=(initial_status,),
  759. )
  760. protocol.create_by = 'owner-user'
  761. db.add_all(
  762. [
  763. protocol,
  764. SysUser(
  765. id='denied-user',
  766. dept_id='denied-dept',
  767. user_no='denied-user',
  768. nick_name='越权用户',
  769. email='',
  770. phone_no='',
  771. password='',
  772. status='1',
  773. tenant_no='tenant-a',
  774. create_dept_id='denied-dept',
  775. del_flag=0,
  776. ),
  777. SysRole(
  778. id='self-only-role',
  779. role_name='仅本人',
  780. data_scope='4',
  781. status='1',
  782. tenant_no='tenant-a',
  783. create_dept_id='denied-dept',
  784. del_flag=0,
  785. ),
  786. SysUserRole(
  787. id='denied-user-role',
  788. user_id='denied-user',
  789. role_id='self-only-role',
  790. tenant_no='tenant-a',
  791. create_dept_id='denied-dept',
  792. del_flag=0,
  793. ),
  794. ]
  795. )
  796. db.commit()
  797. transfer_id = f't-web-scope-{operation}-0'
  798. user = {
  799. 'user_id': 'denied-user',
  800. 'role': 'admin',
  801. 'dept_id': 'denied-dept',
  802. }
  803. if operation in {'update', 'cross-tenant'}:
  804. response = asyncio.run(
  805. payment_api.update_payment_transfer(
  806. transfer_id,
  807. payment_api.TransferDraftBody(transferAmount=99),
  808. user=user,
  809. db=db,
  810. )
  811. )
  812. elif operation == 'submit':
  813. response = asyncio.run(
  814. payment_api.submit_payment_transfer_finance(
  815. transfer_id,
  816. payment_api.TransferDraftBody(transferAmount=99),
  817. user=user,
  818. db=db,
  819. )
  820. )
  821. elif operation == 'withdraw':
  822. response = asyncio.run(
  823. payment_api.withdraw_payment_transfer(
  824. transfer_id,
  825. user=user,
  826. db=db,
  827. )
  828. )
  829. elif operation == 'confirm':
  830. response = asyncio.run(
  831. payment_api.confirm_payment_transfer(
  832. transfer_id,
  833. payment_api.FinanceConfirmBody(financeRemark='must-not-write'),
  834. user=user,
  835. db=db,
  836. )
  837. )
  838. else:
  839. response = asyncio.run(
  840. payment_api.reject_payment_transfer(
  841. transfer_id,
  842. user=user,
  843. db=db,
  844. )
  845. )
  846. assert response['code'] == 403
  847. db.close()
  848. verify_db = sessionmaker(bind=engine)()
  849. persisted = verify_db.get(BizProtocolPaymentTransfer, transfer_id)
  850. assert persisted.pay_status == initial_status
  851. assert persisted.transfer_amount == Decimal('20')
  852. assert persisted.finance_remark is None
  853. verify_db.close()
  854. def test_market_withdraw_locks_schedule_before_transfer(monkeypatch):
  855. from sqlalchemy.orm import Query
  856. from app.services.market_payment import withdraw_transfer
  857. db = _session()
  858. _payment_tree(db, 'market-withdraw-lock', transfer_statuses=('jfqrz',))
  859. locked_entities = []
  860. original = Query.with_for_update
  861. def record_lock(query, *args, **kwargs):
  862. entity = query.column_descriptions[0].get('entity')
  863. if entity in {BizProtocolPaymentSchedule, BizProtocolPaymentTransfer}:
  864. locked_entities.append(entity)
  865. return original(query, *args, **kwargs)
  866. monkeypatch.setattr(Query, 'with_for_update', record_lock)
  867. withdraw_transfer(
  868. db,
  869. transfer_id='t-market-withdraw-lock-0',
  870. ctx=_ctx(),
  871. user_info=_user(),
  872. )
  873. assert locked_entities == [
  874. BizProtocolPaymentSchedule,
  875. BizProtocolPaymentTransfer,
  876. ]
  877. def test_market_can_soft_delete_only_scoped_draft_transfer():
  878. from app.services.market_payment import delete_transfer
  879. db = _session()
  880. _payment_tree(db, 'draft-delete', transfer_statuses=('dfj',))
  881. _payment_tree(db, 'pending-delete', transfer_statuses=('jfqrz',))
  882. _payment_tree(db, 'outside-delete', sales='u2', transfer_statuses=('dfj',))
  883. result = delete_transfer(
  884. db,
  885. transfer_id='t-draft-delete-0',
  886. ctx=_ctx(),
  887. user_info=_user(),
  888. )
  889. assert result == {'id': 't-draft-delete-0'}
  890. assert db.get(BizProtocolPaymentTransfer, 't-draft-delete-0').del_flag == 1
  891. with pytest.raises(ValueError, match='状态'):
  892. delete_transfer(
  893. db,
  894. transfer_id='t-pending-delete-0',
  895. ctx=_ctx(),
  896. user_info=_user(),
  897. )
  898. with pytest.raises(HTTPException):
  899. delete_transfer(
  900. db,
  901. transfer_id='t-outside-delete-0',
  902. ctx=_ctx(),
  903. user_info=_user(),
  904. )
  905. def test_transfer_create_rejects_external_attachment_reference():
  906. from app.services.market_payment import create_transfer
  907. db = _session()
  908. _, _, schedule = _payment_tree(db, 'attachment-reject')
  909. with pytest.raises(ValueError, match='附件'):
  910. create_transfer(
  911. db,
  912. schedule_id=schedule.id,
  913. fields={
  914. 'transferAmount': 10,
  915. 'transferType': 'bank',
  916. 'transferAttachments': 'https://evil.test/a.jpg',
  917. },
  918. ctx=_ctx(),
  919. user_info=_user(),
  920. )
  921. def test_transfer_create_rollback_restores_staging_attachment_on_business_failure(
  922. monkeypatch,
  923. ):
  924. from app.services.market_payment import create_transfer
  925. db = _session()
  926. _, _, schedule = _payment_tree(db, 'attachment-rollback')
  927. attachment = BizAttachment(
  928. id='att-rollback',
  929. business_type='market:specialist:payment:staging',
  930. original_name='rollback.jpg',
  931. file_ext='jpg',
  932. file_url='rollback.jpg',
  933. file_size=10,
  934. file_type='image/jpeg',
  935. tenant_no='tenant-a',
  936. create_by='u1',
  937. create_dept_id='',
  938. del_flag=0,
  939. )
  940. db.add(attachment)
  941. db.commit()
  942. monkeypatch.setattr(
  943. db, 'commit', lambda: (_ for _ in ()).throw(RuntimeError('db down'))
  944. )
  945. with pytest.raises(RuntimeError, match='db down'):
  946. create_transfer(
  947. db,
  948. schedule_id=schedule.id,
  949. fields={
  950. 'transferAmount': 10,
  951. 'transferType': 'bank',
  952. 'transferAttachments': 'att-rollback',
  953. },
  954. ctx=_ctx(),
  955. user_info=_user(),
  956. )
  957. db.expire_all()
  958. restored = db.get(BizAttachment, 'att-rollback')
  959. assert (restored.business_type, restored.business_id) == (
  960. 'market:specialist:payment:staging',
  961. None,
  962. )
  963. assert db.query(BizProtocolPaymentTransfer).count() == 0
  964. def test_out_of_scope_write_is_checked_before_any_mutation():
  965. from app.services.market_payment import update_transfer
  966. db = _session()
  967. _payment_tree(db, 'other', sales='u2', transfer_statuses=('dfj',))
  968. before = db.get(BizProtocolPaymentTransfer, 't-other-0').remark
  969. with pytest.raises(HTTPException):
  970. update_transfer(
  971. db,
  972. transfer_id='t-other-0',
  973. fields={'remark': 'must-not-write'},
  974. ctx=_ctx(),
  975. user_info=_user(),
  976. )
  977. db.expire_all()
  978. assert db.get(BizProtocolPaymentTransfer, 't-other-0').remark == before
  979. def test_transfer_create_rejects_schedule_with_mismatched_payment_parent():
  980. from app.services.market_payment import create_transfer
  981. db = _session()
  982. _, _, schedule = _payment_tree(db, 'create-parent-a')
  983. _, other_payment, _ = _payment_tree(db, 'create-parent-b')
  984. schedule.sample_payment_id = other_payment.id
  985. db.add(schedule)
  986. db.commit()
  987. before = db.query(BizProtocolPaymentTransfer).count()
  988. with pytest.raises(HTTPException) as exc:
  989. create_transfer(
  990. db,
  991. schedule_id=schedule.id,
  992. fields={'transferAmount': 10, 'transferType': 'bank'},
  993. ctx=_ctx(),
  994. user_info=_user(),
  995. )
  996. assert exc.value.status_code == 404
  997. assert db.query(BizProtocolPaymentTransfer).count() == before
  998. @pytest.mark.parametrize(
  999. ('parent_field', 'other_parent_id'),
  1000. [
  1001. ('schedule_id', 'sch-transfer-parent-b'),
  1002. ('sample_payment_id', 'sp-transfer-parent-b'),
  1003. ],
  1004. )
  1005. def test_transfer_mutation_rejects_mismatched_parent_links(
  1006. parent_field, other_parent_id
  1007. ):
  1008. from app.services.market_payment import update_transfer
  1009. db = _session()
  1010. _payment_tree(db, 'transfer-parent-a', transfer_statuses=('dfj',))
  1011. _payment_tree(db, 'transfer-parent-b')
  1012. transfer = db.get(BizProtocolPaymentTransfer, 't-transfer-parent-a-0')
  1013. setattr(transfer, parent_field, other_parent_id)
  1014. db.add(transfer)
  1015. db.commit()
  1016. with pytest.raises(HTTPException) as exc:
  1017. update_transfer(
  1018. db,
  1019. transfer_id=transfer.id,
  1020. fields={'remark': 'must-not-write'},
  1021. ctx=_ctx(),
  1022. user_info=_user(),
  1023. )
  1024. assert exc.value.status_code == 404
  1025. db.expire_all()
  1026. assert db.get(BizProtocolPaymentTransfer, transfer.id).remark is None
  1027. @pytest.mark.parametrize('protocol_status', ['yzf', 'sdfq', 'zky'])
  1028. def test_terminal_protocol_does_not_block_transfer_create_when_schedule_has_arrears(protocol_status):
  1029. from app.services.market_payment import create_transfer
  1030. db = _session()
  1031. _, _, schedule = _payment_tree(
  1032. db, f'terminal-create-{protocol_status}', protocol_status=protocol_status
  1033. )
  1034. created = create_transfer(
  1035. db,
  1036. schedule_id=schedule.id,
  1037. fields={'transferAmount': 10, 'transferType': 'bank'},
  1038. ctx=_ctx(),
  1039. user_info=_user(),
  1040. )
  1041. assert created['scheduleId'] == schedule.id
  1042. def test_cleared_schedule_with_arrears_can_create_transfer_like_web():
  1043. from app.services.market_payment import create_transfer
  1044. db = _session()
  1045. _, _, schedule = _payment_tree(db, 'cleared-create', schedule_status='cleared')
  1046. created = create_transfer(
  1047. db,
  1048. schedule_id=schedule.id,
  1049. fields={'transferAmount': 10, 'transferType': 'bank'},
  1050. ctx=_ctx(),
  1051. user_info=_user(),
  1052. )
  1053. assert created['scheduleId'] == schedule.id
  1054. @pytest.mark.parametrize(
  1055. ('schedule_status', 'confirmed_count'),
  1056. [('void', 0), ('open', 5)],
  1057. )
  1058. def test_transfer_create_rejects_void_or_no_arrears_after_locked_recheck(
  1059. schedule_status, confirmed_count
  1060. ):
  1061. from app.services.market_payment import create_transfer
  1062. db = _session()
  1063. _, _, schedule = _payment_tree(
  1064. db,
  1065. f'create-reject-{schedule_status}-{confirmed_count}',
  1066. schedule_status=schedule_status,
  1067. transfer_statuses=('yjf',) * confirmed_count,
  1068. )
  1069. before = db.query(BizProtocolPaymentTransfer).count()
  1070. with pytest.raises((HTTPException, ValueError), match='应缴|欠缴'):
  1071. create_transfer(
  1072. db,
  1073. schedule_id=schedule.id,
  1074. fields={'transferAmount': 10, 'transferType': 'bank'},
  1075. ctx=_ctx(),
  1076. user_info=_user(),
  1077. )
  1078. assert db.query(BizProtocolPaymentTransfer).count() == before
  1079. @pytest.mark.parametrize(
  1080. ('protocol_status', 'schedule_status', 'pay_status', 'operation'),
  1081. [
  1082. ('yzf', 'open', 'dfj', 'update'),
  1083. ('sdfq', 'open', 'dfj', 'submit'),
  1084. ('zky', 'open', 'jfqrz', 'withdraw'),
  1085. ('sxz', 'cleared', 'dfj', 'update'),
  1086. ('sxz', 'void', 'dfj', 'submit'),
  1087. ],
  1088. )
  1089. def test_existing_transfer_mutations_depend_on_transfer_status_not_parent_state(
  1090. protocol_status, schedule_status, pay_status, operation
  1091. ):
  1092. from app.services.market_payment import submit_transfer, update_transfer, withdraw_transfer
  1093. db = _session()
  1094. key = f'ineligible-{protocol_status}-{schedule_status}-{operation}'
  1095. _payment_tree(
  1096. db,
  1097. key,
  1098. protocol_status=protocol_status,
  1099. schedule_status=schedule_status,
  1100. transfer_statuses=(pay_status,),
  1101. )
  1102. transfer_id = f't-{key}-0'
  1103. if operation == 'update':
  1104. result = update_transfer(
  1105. db,
  1106. transfer_id=transfer_id,
  1107. fields={'remark': 'allowed'},
  1108. ctx=_ctx(),
  1109. user_info=_user(),
  1110. )
  1111. assert result['remark'] == 'allowed'
  1112. elif operation == 'submit':
  1113. result = submit_transfer(
  1114. db, transfer_id=transfer_id, ctx=_ctx(), user_info=_user()
  1115. )
  1116. assert result['payStatus'] == 'jfqrz'
  1117. else:
  1118. result = withdraw_transfer(
  1119. db, transfer_id=transfer_id, ctx=_ctx(), user_info=_user()
  1120. )
  1121. assert result['payStatus'] == 'dfj'
  1122. def test_chase_includes_terminal_and_cleared_non_void_schedules_like_web():
  1123. from app.services.market_payment import list_payment_chase
  1124. db = _session()
  1125. _payment_tree(db, 'eligible-chase')
  1126. for status in ('yzf', 'sdfq', 'zky'):
  1127. _payment_tree(db, f'terminal-chase-{status}', protocol_status=status)
  1128. _payment_tree(db, 'cleared-chase', schedule_status='cleared')
  1129. _payment_tree(db, 'void-chase', schedule_status='void')
  1130. items, total = list_payment_chase(db, ctx=_ctx(), user_info=_user())
  1131. assert total == 5
  1132. assert {item['scheduleId'] for item in items} == {
  1133. 'sch-eligible-chase',
  1134. 'sch-terminal-chase-yzf',
  1135. 'sch-terminal-chase-sdfq',
  1136. 'sch-terminal-chase-zky',
  1137. 'sch-cleared-chase',
  1138. }
  1139. def test_yjf_is_immutable_and_double_submit_fails_closed():
  1140. from app.services.market_payment import submit_transfer, update_transfer, withdraw_transfer
  1141. db = _session()
  1142. _payment_tree(db, 'draft', transfer_statuses=('dfj',))
  1143. submit_transfer(db, transfer_id='t-draft-0', ctx=_ctx(), user_info=_user())
  1144. with pytest.raises(ValueError, match='状态'):
  1145. submit_transfer(db, transfer_id='t-draft-0', ctx=_ctx(), user_info=_user())
  1146. _payment_tree(db, 'confirmed', transfer_statuses=('yjf',))
  1147. with pytest.raises(ValueError):
  1148. update_transfer(
  1149. db,
  1150. transfer_id='t-confirmed-0',
  1151. fields={'remark': 'no'},
  1152. ctx=_ctx(),
  1153. user_info=_user(),
  1154. )
  1155. with pytest.raises(ValueError):
  1156. withdraw_transfer(
  1157. db,
  1158. transfer_id='t-confirmed-0',
  1159. ctx=_ctx(),
  1160. user_info=_user(),
  1161. )
  1162. def test_transfer_dto_forbids_authorization_status_and_finance_fields():
  1163. from app.schemas.market_mobile import MarketPaymentTransferBody
  1164. forbidden = (
  1165. {'tenantNo': 'tenant-b'},
  1166. {'scope': 'manager'},
  1167. {'payStatus': 'yjf'},
  1168. {'financeRemark': 'confirmed'},
  1169. {'scheduleStatus': 'void'},
  1170. )
  1171. for extra in forbidden:
  1172. with pytest.raises(ValidationError):
  1173. MarketPaymentTransferBody.model_validate(
  1174. {'transferAmount': 10, 'transferType': 'bank', **extra}
  1175. )
  1176. @pytest.mark.parametrize(
  1177. 'amount',
  1178. [float('inf'), float('-inf'), float('nan'), '0.001', '10000000000000000.00'],
  1179. )
  1180. def test_transfer_amount_rejects_non_finite_over_precision_and_overflow(amount):
  1181. from app.schemas.market_mobile import MarketPaymentTransferBody
  1182. with pytest.raises(ValidationError):
  1183. MarketPaymentTransferBody.model_validate({'transferAmount': amount})
  1184. @pytest.mark.parametrize('amount', ['0.01', '9999999999999999.99'])
  1185. def test_transfer_amount_accepts_decimal_18_2_boundaries_exactly(amount):
  1186. from app.schemas.market_mobile import MarketPaymentTransferBody
  1187. body = MarketPaymentTransferBody.model_validate({'transferAmount': amount})
  1188. assert body.transfer_amount == Decimal(amount)
  1189. def test_task5_money_is_fixed_two_decimal_string_in_actual_json_response():
  1190. from app.core.response import success_response
  1191. from app.schemas.market_mobile import MarketPaymentTransferCreateBody
  1192. from app.services.market_payment import _payment_dict, create_transfer
  1193. db = _session()
  1194. _, _, schedule = _payment_tree(
  1195. db, 'money-json', due_amount=Decimal('9999999999999999.99')
  1196. )
  1197. body = MarketPaymentTransferCreateBody.model_validate(
  1198. {
  1199. 'scheduleId': schedule.id,
  1200. 'transferAmount': '9999999999999999.99',
  1201. 'transferType': 'bank',
  1202. }
  1203. )
  1204. payload = body.model_dump(by_alias=True, exclude_unset=True)
  1205. schedule_id = payload.pop('scheduleId')
  1206. transfer = create_transfer(
  1207. db,
  1208. schedule_id=schedule_id,
  1209. fields=payload,
  1210. ctx=_ctx(),
  1211. user_info=_user(),
  1212. )
  1213. max_money = Decimal('9999999999999999.99')
  1214. payment = _payment_dict(
  1215. SimpleNamespace(
  1216. id='sp-json',
  1217. sample_code='S-json',
  1218. pay_mode='fq',
  1219. contract_amount=max_money,
  1220. remark=None,
  1221. ),
  1222. SimpleNamespace(id='p-json', protocol_no='P-json', protocol_status='sxz'),
  1223. None,
  1224. [
  1225. SimpleNamespace(
  1226. id='sch-json',
  1227. schedule_no=1,
  1228. due_amount=max_money,
  1229. due_date='20261011',
  1230. schedule_status='open',
  1231. )
  1232. ],
  1233. {},
  1234. )
  1235. encoded = jsonable_encoder(
  1236. success_response({'transfer': transfer, 'payment': payment})
  1237. )
  1238. response = JSONResponse(encoded)
  1239. decoded = response.body.decode('utf-8')
  1240. assert transfer['transferAmount'] == '9999999999999999.99'
  1241. assert payment['contractAmount'] == '9999999999999999.99'
  1242. assert payment['schedules'][0]['dueAmount'] == '9999999999999999.99'
  1243. assert '"9999999999999999.99"' in decoded
  1244. def test_task5_normal_money_is_always_serialized_with_two_decimals():
  1245. from app.services.market_payment import list_payment_chase
  1246. db = _session()
  1247. _payment_tree(db, 'normal-money', due_amount=Decimal('100.00'))
  1248. items, _ = list_payment_chase(db, ctx=_ctx(), user_info=_user())
  1249. assert items[0]['dueAmount'] == '100.00'
  1250. assert items[0]['paidAmount'] == '0.00'
  1251. assert items[0]['arrearsAmount'] == '100.00'
  1252. def test_market_routes_expose_only_allowed_payment_mutations():
  1253. from app.api.market_mobile import router
  1254. paths = {route.path for route in router.routes}
  1255. for scope in ('specialist', 'manager'):
  1256. prefix = f'/mini/market/{scope}'
  1257. assert f'{prefix}/payment-chase' in paths
  1258. assert f'{prefix}/payment-chase/{{schedule_id}}' in paths
  1259. assert f'{prefix}/payment-chases' not in paths
  1260. assert f'{prefix}/payments' in paths
  1261. assert f'{prefix}/payment-transfers' in paths
  1262. assert f'{prefix}/payment-transfers/{{transfer_id}}/submit' in paths
  1263. assert f'{prefix}/payment-transfers/{{transfer_id}}/withdraw' in paths
  1264. assert f'{prefix}/payment-transfers/{{transfer_id}}/confirm' not in paths
  1265. assert f'{prefix}/payment-transfers/{{transfer_id}}/reject' not in paths
  1266. assert f'{prefix}/payment-transfers/{{transfer_id}}/refund' not in paths
  1267. assert f'{prefix}/payment-transfers/{{transfer_id}}/invoice' not in paths
  1268. def test_chase_detail_route_accepts_chase_only_menu_and_denies_missing_menu(monkeypatch):
  1269. from app.api.market_mobile import router
  1270. from app.core import mobile_menu_guard
  1271. route = next(
  1272. item
  1273. for item in router.routes
  1274. if item.path == '/mini/market/specialist/payment-chase/{schedule_id}'
  1275. )
  1276. guard = next(
  1277. dependency.call
  1278. for dependency in route.dependant.dependencies
  1279. if dependency.name == 'user'
  1280. )
  1281. requested = []
  1282. monkeypatch.setattr(
  1283. mobile_menu_guard,
  1284. 'resolve_web_role_for_user',
  1285. lambda *_args, **_kwargs: 'market_specialist',
  1286. )
  1287. monkeypatch.setattr(
  1288. mobile_menu_guard,
  1289. 'user_has_tenant_menu_perm',
  1290. lambda _db, **kwargs: requested.append(kwargs['perm_code'])
  1291. or kwargs['perm_code'] == 'mini:market:specialist:payment-chase',
  1292. )
  1293. assert asyncio.run(guard(user=_user(), db=object())) == _user()
  1294. assert requested == ['mini:market:specialist:payment-chase']
  1295. monkeypatch.setattr(
  1296. mobile_menu_guard,
  1297. 'user_has_tenant_menu_perm',
  1298. lambda *_args, **_kwargs: False,
  1299. )
  1300. with pytest.raises(HTTPException) as exc:
  1301. asyncio.run(guard(user=_user(), db=object()))
  1302. assert exc.value.status_code == 403
  1303. def test_list_queries_batch_children_without_n_plus_one():
  1304. from app.services.market_payment import list_payments
  1305. db = _session()
  1306. for index in range(8):
  1307. _payment_tree(db, f'row-{index}', transfer_statuses=('dfj', 'yjf'))
  1308. statements = []
  1309. def capture(_conn, _cursor, statement, _parameters, _context, _many):
  1310. statements.append(statement)
  1311. event.listen(db.get_bind(), 'before_cursor_execute', capture)
  1312. try:
  1313. items, total = list_payments(
  1314. db, ctx=_ctx(), user_info=_user(), page=1, page_size=5
  1315. )
  1316. finally:
  1317. event.remove(db.get_bind(), 'before_cursor_execute', capture)
  1318. assert total == 8
  1319. assert len(items) == 5
  1320. assert len(statements) <= 5
  1321. def test_payment_list_contains_card_summary_totals():
  1322. from app.services.market_payment import list_payments
  1323. db = _session()
  1324. _payment_tree(db, 'summary', due_amount=Decimal('100.00'), transfer_statuses=('yjf',))
  1325. transfer = db.get(BizProtocolPaymentTransfer, 't-summary-0')
  1326. transfer.finance_remark = '财务已确认'
  1327. db.commit()
  1328. items, total = list_payments(db, ctx=_ctx(), user_info=_user())
  1329. assert total == 1
  1330. assert items[0]['paidAmount'] == '20.00'
  1331. assert items[0]['remainAmount'] == '80.00'
  1332. assert items[0]['activeScheduleCount'] == 1
  1333. assert items[0]['clearedScheduleCount'] == 0
  1334. assert items[0]['paymentProgress'] == '部分已缴'
  1335. assert items[0]['signDate'] == '2026-10-01'
  1336. assert items[0]['storageYears'] == 20
  1337. assert items[0]['sampleTypeName'] == '样本类型-summary'
  1338. assert items[0]['discountAmount'] == '20.00'
  1339. assert items[0]['receivableAmount'] == '100.00'
  1340. assert items[0]['dueRemainAmount'] == '80.00'
  1341. assert len(items[0]['periods']) == 6
  1342. assert items[0]['periods'][0]['scheduleNo'] == 1
  1343. assert items[0]['periods'][0]['displayPaidAmount'] == 20.0
  1344. assert items[0]['periods'][0]['payDate']
  1345. assert items[0]['periods'][0]['transfers'][0]['financeRemark'] == '财务已确认'
  1346. assert items[0]['schedules'][0]['displayPaidAmount'] == '20.00'
  1347. assert items[0]['schedules'][0]['payDate']
  1348. def test_payment_sample_meta_uses_tenant_and_sample_code_after_protocol_change():
  1349. from app.services.market_payment import list_payments
  1350. db = _session()
  1351. _, payment, _ = _payment_tree(db, 'protocol-changed')
  1352. sample = (
  1353. db.query(BizProtocolSample)
  1354. .filter(BizProtocolSample.sample_code == payment.sample_code)
  1355. .one()
  1356. )
  1357. sample.collection_protocol_id = 'p-previous-protocol'
  1358. db.commit()
  1359. items, total = list_payments(db, ctx=_ctx(), user_info=_user())
  1360. assert total == 1
  1361. assert items[0]['storageYears'] == 20
  1362. assert items[0]['sampleTypeName'] == '样本类型-protocol-changed'
  1363. assert items[0]['discountAmount'] == '20.00'