test_market_presign_api.py 77 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552
  1. import pytest
  2. from fastapi import HTTPException
  3. from sqlalchemy import create_engine, event
  4. from sqlalchemy.exc import IntegrityError
  5. from sqlalchemy.orm import sessionmaker
  6. from app.models.base import Base
  7. from app.models.models import (
  8. BizAttachment,
  9. BizCellSampleType,
  10. BizCollectionHospital,
  11. BizCollectionProtocol,
  12. BizCollectionRegion,
  13. BizCollectionRegionStaff,
  14. BizBizNoSequence,
  15. BizPresignOrder,
  16. BizPresignOrderSampleType,
  17. BizPresignTransfer,
  18. BizProtocolCollectionInfo,
  19. BizProtocolPartyCustomer,
  20. BizProtocolSample,
  21. SysPost,
  22. SysPostRole,
  23. SysRole,
  24. SysRoleDataScopeDetail,
  25. SysUser,
  26. SysUserPost,
  27. SysUserRole,
  28. )
  29. from app.models.research_donation import (
  30. BizResearchDonation,
  31. BizResearchDonationSample,
  32. )
  33. _TEST_TABLES = [
  34. BizCollectionRegionStaff.__table__,
  35. BizCollectionRegion.__table__,
  36. BizCollectionHospital.__table__,
  37. BizBizNoSequence.__table__,
  38. BizPresignOrder.__table__,
  39. BizPresignOrderSampleType.__table__,
  40. BizPresignTransfer.__table__,
  41. BizCellSampleType.__table__,
  42. BizCollectionProtocol.__table__,
  43. BizProtocolPartyCustomer.__table__,
  44. BizProtocolCollectionInfo.__table__,
  45. BizProtocolSample.__table__,
  46. BizResearchDonation.__table__,
  47. BizResearchDonationSample.__table__,
  48. BizAttachment.__table__,
  49. SysUser.__table__,
  50. SysRole.__table__,
  51. SysRoleDataScopeDetail.__table__,
  52. SysUserRole.__table__,
  53. SysPost.__table__,
  54. SysPostRole.__table__,
  55. SysUserPost.__table__,
  56. ]
  57. def _session_factory(database_url="sqlite:///:memory:"):
  58. engine = create_engine(database_url)
  59. Base.metadata.create_all(
  60. engine,
  61. tables=_TEST_TABLES,
  62. )
  63. return sessionmaker(bind=engine)
  64. def _session():
  65. return _session_factory()()
  66. def _order(
  67. order_id,
  68. *,
  69. tenant_no="tenant-a",
  70. region_id="region-a",
  71. sales_user_id="sales-a",
  72. manager_user_id="manager-a",
  73. ):
  74. return BizPresignOrder(
  75. id=order_id,
  76. tenant_no=tenant_no,
  77. region_id=region_id,
  78. sales_user_id=sales_user_id,
  79. region_manager_user_id=manager_user_id,
  80. order_status="active",
  81. create_by="creator-a",
  82. create_dept_id="dept-a",
  83. del_flag=0,
  84. )
  85. def _transfer(
  86. transfer_id,
  87. *,
  88. order_id="own",
  89. tenant_no="tenant-a",
  90. pay_status="dfj",
  91. ):
  92. return BizPresignTransfer(
  93. id=transfer_id,
  94. presign_order_id=order_id,
  95. transfer_amount=100,
  96. transfer_type="wechat",
  97. pay_status=pay_status,
  98. tenant_no=tenant_no,
  99. create_by="creator-a",
  100. create_dept_id="dept-a",
  101. del_flag=0,
  102. )
  103. def _ctx(scope_kind, user_id="u1"):
  104. from app.services.market_field_scope import MarketFieldContext
  105. return MarketFieldContext("tenant-a", user_id, scope_kind)
  106. def _user(user_id="u1"):
  107. return {
  108. "user_id": user_id,
  109. "tenant_no": "tenant-a",
  110. "role": "super_admin",
  111. "dept_id": "dept-a",
  112. }
  113. def _real_auth_user(user_id="u1"):
  114. return {
  115. "user_id": user_id,
  116. "tenant_no": "tenant-a",
  117. "role": "super_admin",
  118. "region_id": "dept-from-auth",
  119. }
  120. def test_converted_presign_rejects_update_and_convert_actions():
  121. from app.schemas.market_mobile import MarketPresignOrderBody
  122. from app.services.market_presign import convert_presign_order, update_presign_order
  123. db = _session()
  124. _add_master_data(db)
  125. db.add(_order('terminal', sales_user_id='u1'))
  126. db.commit()
  127. row = db.query(BizPresignOrder).filter_by(id='terminal').one()
  128. row.order_status = 'converted'
  129. db.commit()
  130. with pytest.raises(ValueError, match='不可|已转'):
  131. update_presign_order(
  132. db,
  133. order_id='terminal',
  134. body=MarketPresignOrderBody(remark='不应保存'),
  135. ctx=_ctx('specialist'),
  136. user_info=_user(),
  137. )
  138. with pytest.raises(ValueError, match='不可|已转'):
  139. convert_presign_order(
  140. db,
  141. order_id='terminal',
  142. ctx=_ctx('specialist'),
  143. user_info=_user(),
  144. )
  145. def test_void_presign_allows_update_but_rejects_convert():
  146. from app.schemas.market_mobile import MarketPresignOrderBody
  147. from app.services.market_presign import convert_presign_order, update_presign_order
  148. db = _session()
  149. _add_master_data(db)
  150. db.add(_order('void-order', sales_user_id='u1'))
  151. db.commit()
  152. row = db.query(BizPresignOrder).filter_by(id='void-order').one()
  153. row.order_status = 'void'
  154. db.commit()
  155. data = update_presign_order(
  156. db,
  157. order_id='void-order',
  158. body=MarketPresignOrderBody(remark='作废单仍可修订'),
  159. ctx=_ctx('specialist'),
  160. user_info=_user(),
  161. )
  162. assert data['remark'] == '作废单仍可修订'
  163. with pytest.raises(ValueError, match='不可|作废'):
  164. convert_presign_order(
  165. db,
  166. order_id='void-order',
  167. ctx=_ctx('specialist'),
  168. user_info=_user(),
  169. )
  170. def test_presign_create_rejects_unregistered_transfer_attachment_url():
  171. from app.schemas.market_mobile import MarketPresignOrderBody
  172. from app.services.market_presign import create_presign_order
  173. db = _session()
  174. _add_master_data(db)
  175. body = MarketPresignOrderBody.model_validate(
  176. {
  177. 'regionId': 'region-a',
  178. 'hospitalId': 'hospital-a',
  179. 'transfers': [
  180. {
  181. 'transferAmount': 100,
  182. 'transferAttachments': 'https://evil.test/proof.pdf',
  183. }
  184. ],
  185. }
  186. )
  187. with pytest.raises(ValueError, match='附件'):
  188. create_presign_order(
  189. db,
  190. body=body,
  191. ctx=_ctx('specialist'),
  192. user_info=_user(),
  193. )
  194. @pytest.mark.parametrize('order_status', ['converted', 'void'])
  195. def test_presign_create_rejects_embedded_transfer_for_non_writable_order(
  196. order_status,
  197. ):
  198. from app.schemas.market_mobile import MarketPresignOrderBody
  199. from app.services.market_presign import create_presign_order
  200. db = _session()
  201. _add_master_data(db)
  202. body = MarketPresignOrderBody.model_validate(
  203. {
  204. 'regionId': 'region-a',
  205. 'hospitalId': 'hospital-a',
  206. 'orderStatus': order_status,
  207. 'transfers': [
  208. {
  209. 'transferAmount': 100,
  210. 'transferType': 'bank',
  211. }
  212. ],
  213. }
  214. )
  215. with pytest.raises(ValueError, match='订单|状态|转账'):
  216. create_presign_order(
  217. db,
  218. body=body,
  219. ctx=_ctx('specialist'),
  220. user_info=_user(),
  221. )
  222. assert db.query(BizPresignOrder).count() == 0
  223. assert db.query(BizPresignTransfer).count() == 0
  224. def _add_master_data(db):
  225. db.add_all(
  226. [
  227. BizCollectionRegion(
  228. id="region-a",
  229. region_name="A区",
  230. tenant_no="tenant-a",
  231. create_dept_id="",
  232. del_flag=0,
  233. ),
  234. BizCollectionRegion(
  235. id="region-b",
  236. region_name="B区",
  237. tenant_no="tenant-a",
  238. create_dept_id="",
  239. del_flag=0,
  240. ),
  241. BizCollectionRegion(
  242. id="region-cross",
  243. region_name="跨租户地区",
  244. tenant_no="tenant-b",
  245. create_dept_id="",
  246. del_flag=0,
  247. ),
  248. BizCollectionHospital(
  249. id="hospital-a",
  250. region_id="region-a",
  251. hospital_name="A医院",
  252. tenant_no="tenant-a",
  253. create_dept_id="",
  254. del_flag=0,
  255. ),
  256. BizCollectionHospital(
  257. id="hospital-b",
  258. region_id="region-b",
  259. hospital_name="B医院",
  260. tenant_no="tenant-a",
  261. create_dept_id="",
  262. del_flag=0,
  263. ),
  264. BizCellSampleType(
  265. id="sample-a",
  266. type_code="A",
  267. type_name="样本A",
  268. tenant_no="tenant-a",
  269. create_dept_id="",
  270. del_flag=0,
  271. ),
  272. SysUser(
  273. id="u1",
  274. user_no="u1",
  275. nick_name="当前用户",
  276. status="1",
  277. tenant_no="tenant-a",
  278. create_dept_id="dept-a",
  279. del_flag=0,
  280. ),
  281. SysUser(
  282. id="collector-a",
  283. user_no="collector-a",
  284. nick_name="有效采集员",
  285. status="1",
  286. tenant_no="tenant-a",
  287. create_dept_id="dept-a",
  288. del_flag=0,
  289. ),
  290. SysUser(
  291. id="manager-a",
  292. user_no="manager-a",
  293. nick_name="有效经理",
  294. status="1",
  295. tenant_no="tenant-a",
  296. create_dept_id="dept-a",
  297. del_flag=0,
  298. ),
  299. SysUser(
  300. id="cross-user",
  301. user_no="cross-secret",
  302. nick_name="跨租户秘密姓名",
  303. status="1",
  304. tenant_no="tenant-b",
  305. create_dept_id="dept-b",
  306. del_flag=0,
  307. ),
  308. SysUser(
  309. id="deleted-user",
  310. user_no="deleted-secret",
  311. nick_name="已删除秘密姓名",
  312. status="1",
  313. tenant_no="tenant-a",
  314. create_dept_id="dept-a",
  315. del_flag=1,
  316. ),
  317. SysUser(
  318. id="disabled-user",
  319. user_no="disabled-secret",
  320. nick_name="停用秘密姓名",
  321. status="0",
  322. tenant_no="tenant-a",
  323. create_dept_id="dept-a",
  324. del_flag=0,
  325. ),
  326. BizCellSampleType(
  327. id="sample-cross",
  328. type_code="X",
  329. type_name="跨租户样本",
  330. tenant_no="tenant-b",
  331. create_dept_id="",
  332. del_flag=0,
  333. ),
  334. ]
  335. )
  336. db.commit()
  337. def test_specialist_presign_list_only_returns_own_sales_orders():
  338. from app.services.market_presign import list_presign_orders
  339. db = _session()
  340. db.add_all(
  341. [
  342. _order("own", sales_user_id="u1"),
  343. _order("other", sales_user_id="u2"),
  344. _order("cross-tenant", tenant_no="tenant-b", sales_user_id="u1"),
  345. ]
  346. )
  347. db.commit()
  348. items, total = list_presign_orders(
  349. db, ctx=_ctx("specialist"), user_info=_user()
  350. )
  351. assert total == 1
  352. assert [item["id"] for item in items] == ["own"]
  353. def test_manager_presign_list_uses_region_or_manager_union():
  354. from app.services.market_presign import list_presign_orders
  355. db = _session()
  356. db.add(
  357. BizCollectionRegionStaff(
  358. id="leader",
  359. region_id="region-led",
  360. user_id="u1",
  361. is_leader=1,
  362. tenant_no="tenant-a",
  363. create_dept_id="dept-a",
  364. del_flag=0,
  365. )
  366. )
  367. db.add_all(
  368. [
  369. _order("by-region", region_id="region-led", manager_user_id="u2"),
  370. _order("by-manager", region_id="region-other", manager_user_id="u1"),
  371. _order("outside", region_id="region-other", manager_user_id="u2"),
  372. ]
  373. )
  374. db.commit()
  375. items, total = list_presign_orders(
  376. db, ctx=_ctx("manager"), user_info=_user()
  377. )
  378. assert total == 2
  379. assert {item["id"] for item in items} == {"by-region", "by-manager"}
  380. def test_presign_list_batches_deposits_and_user_names_without_n_plus_one():
  381. from app.services.market_presign import list_presign_orders
  382. db = _session()
  383. db.add(
  384. SysUser(
  385. id="u1",
  386. user_no="u1",
  387. nick_name="当前用户",
  388. status="1",
  389. tenant_no="tenant-a",
  390. create_dept_id="dept-a",
  391. del_flag=0,
  392. )
  393. )
  394. db.add_all(
  395. [_order(f"order-{index}", sales_user_id="u1") for index in range(3)]
  396. )
  397. db.commit()
  398. statements = []
  399. def capture_statement(*args):
  400. statements.append(args[2])
  401. event.listen(db.get_bind(), "before_cursor_execute", capture_statement)
  402. try:
  403. items, total = list_presign_orders(
  404. db, ctx=_ctx("specialist"), user_info=_user(), page_size=10
  405. )
  406. finally:
  407. event.remove(db.get_bind(), "before_cursor_execute", capture_statement)
  408. assert total == 3
  409. assert len(items) == 3
  410. assert len(statements) <= 6
  411. def test_presign_list_and_detail_resolve_referenced_master_names_in_batches():
  412. from app.services.market_presign import get_presign_order, list_presign_orders
  413. db = _session()
  414. _add_master_data(db)
  415. db.add_all(
  416. [
  417. BizCollectionRegion(id='region-deleted', region_name='历史地区', tenant_no='tenant-a', create_dept_id='', del_flag=1),
  418. BizCollectionHospital(id='hospital-deleted', region_id='region-deleted', hospital_name='历史医院', tenant_no='tenant-a', create_dept_id='', del_flag=1),
  419. BizCellSampleType(id='sample-deleted', type_code='D', type_name='历史样本', tenant_no='tenant-a', create_dept_id='', del_flag=1),
  420. BizCollectionHospital(id='hospital-cross', region_id='region-cross', hospital_name='跨租户医院', tenant_no='tenant-b', create_dept_id='', del_flag=1),
  421. ]
  422. )
  423. normal = _order('normal-master', region_id='region-a', sales_user_id='u1')
  424. normal.hospital_id = 'hospital-a'
  425. historical = _order(
  426. 'historical-master', region_id='region-deleted', sales_user_id='u1'
  427. )
  428. historical.hospital_id = 'hospital-deleted'
  429. cross = _order('cross-master', region_id='region-cross', sales_user_id='u1')
  430. cross.hospital_id = 'hospital-cross'
  431. db.add_all(
  432. [
  433. normal,
  434. historical,
  435. cross,
  436. BizPresignOrderSampleType(id='normal-master-type', presign_order_id=normal.id, sample_type_id='sample-a', tenant_no='tenant-a', create_dept_id='', order_num=1, del_flag=0),
  437. BizPresignOrderSampleType(id='historical-master-type', presign_order_id=historical.id, sample_type_id='sample-deleted', tenant_no='tenant-a', create_dept_id='', order_num=1, del_flag=0),
  438. BizPresignOrderSampleType(id='cross-master-type', presign_order_id=cross.id, sample_type_id='sample-cross', tenant_no='tenant-a', create_dept_id='', order_num=1, del_flag=0),
  439. ]
  440. )
  441. db.commit()
  442. statements = []
  443. def capture_statement(*args):
  444. statements.append(args[2])
  445. event.listen(db.get_bind(), 'before_cursor_execute', capture_statement)
  446. try:
  447. items, total = list_presign_orders(
  448. db, ctx=_ctx('specialist'), user_info=_user(), page_size=10
  449. )
  450. finally:
  451. event.remove(db.get_bind(), 'before_cursor_execute', capture_statement)
  452. by_id = {item['id']: item for item in items}
  453. normal_detail = get_presign_order(
  454. db,
  455. order_id=normal.id,
  456. ctx=_ctx('specialist'),
  457. user_info=_user(),
  458. )
  459. historical_detail = get_presign_order(
  460. db,
  461. order_id=historical.id,
  462. ctx=_ctx('specialist'),
  463. user_info=_user(),
  464. )
  465. assert total == 3
  466. assert by_id[normal.id]['regionName'] == 'A区'
  467. assert by_id[normal.id]['hospitalName'] == 'A医院'
  468. assert by_id[normal.id]['sampleTypeIds'] == ['sample-a']
  469. assert by_id[normal.id]['sampleTypeNames'] == ['样本A']
  470. assert normal_detail['regionName'] == 'A区'
  471. assert normal_detail['hospitalName'] == 'A医院'
  472. assert normal_detail['sampleTypeNames'] == ['样本A']
  473. assert by_id[historical.id]['regionName'] == '历史地区'
  474. assert by_id[historical.id]['hospitalName'] == '历史医院'
  475. assert by_id[historical.id]['sampleTypeNames'] == ['历史样本']
  476. assert historical_detail['regionName'] == '历史地区'
  477. assert historical_detail['hospitalName'] == '历史医院'
  478. assert historical_detail['sampleTypeNames'] == ['历史样本']
  479. assert by_id[cross.id]['regionName'] == ''
  480. assert by_id[cross.id]['hospitalName'] == ''
  481. assert by_id[cross.id]['sampleTypeNames'] == ['']
  482. assert len(statements) <= 8
  483. def test_presign_response_uses_historical_nicknames_without_raw_fallbacks():
  484. from app.services.market_presign import list_presign_orders
  485. db = _session()
  486. _add_master_data(db)
  487. db.get(SysUser, 'u1').nick_name = ''
  488. db.add_all(
  489. [
  490. _order(
  491. "own",
  492. sales_user_id="u1",
  493. manager_user_id="disabled-user",
  494. ),
  495. _order(
  496. "cross-reference",
  497. sales_user_id="u1",
  498. manager_user_id="cross-user",
  499. ),
  500. ]
  501. )
  502. row = db.query(BizPresignOrder).filter_by(id="own").one_or_none()
  503. if row is None:
  504. db.flush()
  505. row = db.query(BizPresignOrder).filter_by(id="own").one()
  506. row.collector_user_id = "deleted-user"
  507. db.commit()
  508. items, total = list_presign_orders(
  509. db, ctx=_ctx("specialist"), user_info=_user()
  510. )
  511. by_id = {item['id']: item for item in items}
  512. assert total == 2
  513. assert by_id['own']["salesUserName"] == ""
  514. assert by_id['own']["collectorUserName"] == "已删除秘密姓名"
  515. assert by_id['own']["regionManagerUserName"] == "停用秘密姓名"
  516. assert by_id['cross-reference']["regionManagerUserName"] == ""
  517. assert "跨租户秘密姓名" not in str(items)
  518. def test_presign_detail_rejects_guessed_out_of_scope_id():
  519. from app.services.market_presign import get_presign_order
  520. db = _session()
  521. db.add(_order("other", sales_user_id="u2"))
  522. db.commit()
  523. with pytest.raises(HTTPException) as exc_info:
  524. get_presign_order(
  525. db,
  526. order_id="other",
  527. ctx=_ctx("specialist"),
  528. user_info=_user(),
  529. )
  530. assert exc_info.value.status_code == 404
  531. def test_presign_body_rejects_client_controlled_sales_user():
  532. from pydantic import ValidationError
  533. from app.schemas.market_mobile import MarketPresignOrderBody
  534. with pytest.raises(ValidationError):
  535. MarketPresignOrderBody.model_validate({'salesUserId': 'attacker-selected-user'})
  536. @pytest.mark.parametrize('payload', [{'motherName': ' '}, {'sampleTypeIds': []}])
  537. def test_presign_update_body_rejects_explicitly_empty_required_fields(payload):
  538. from pydantic import ValidationError
  539. from app.schemas.market_mobile import MarketPresignOrderBody
  540. with pytest.raises(ValidationError):
  541. MarketPresignOrderBody.model_validate(payload)
  542. def test_specialist_create_forces_current_sales_user_and_accepts_order_status():
  543. from app.schemas.market_mobile import MarketPresignOrderBody
  544. from app.services.market_presign import create_presign_order
  545. db = _session()
  546. _add_master_data(db)
  547. body = MarketPresignOrderBody.model_validate(
  548. {
  549. "motherName": "测试客户",
  550. "regionId": "region-a",
  551. "hospitalId": "hospital-a",
  552. "sampleTypeIds": ["sample-a"],
  553. "orderStatus": "void",
  554. }
  555. )
  556. data = create_presign_order(
  557. db,
  558. body=body,
  559. ctx=_ctx("specialist"),
  560. user_info=_user(),
  561. )
  562. row = db.query(BizPresignOrder).filter_by(id=data["id"]).one()
  563. assert row.tenant_no == "tenant-a"
  564. assert row.sales_user_id == "u1"
  565. assert row.order_status == "void"
  566. def test_create_rejects_client_requested_converted_status_without_writing():
  567. from app.schemas.market_mobile import MarketPresignOrderBody
  568. from app.services.market_presign import create_presign_order
  569. db = _session()
  570. _add_master_data(db)
  571. with pytest.raises(ValueError, match='转正式|converted'):
  572. create_presign_order(
  573. db,
  574. body=MarketPresignOrderBody.model_validate(
  575. {
  576. 'motherName': '不可直转客户',
  577. 'regionId': 'region-a',
  578. 'hospitalId': 'hospital-a',
  579. 'orderStatus': 'converted',
  580. }
  581. ),
  582. ctx=_ctx('specialist'),
  583. user_info=_user(),
  584. )
  585. assert db.query(BizPresignOrder).count() == 0
  586. @pytest.mark.parametrize('current_status', ['active', 'draft', 'void'])
  587. def test_update_rejects_client_requested_converted_status_without_writing(
  588. current_status,
  589. ):
  590. from app.schemas.market_mobile import MarketPresignOrderBody
  591. from app.services.market_presign import update_presign_order
  592. db = _session()
  593. _add_master_data(db)
  594. order = _order('own', sales_user_id='u1')
  595. order.order_status = current_status
  596. order.remark = 'before'
  597. db.add(order)
  598. db.commit()
  599. with pytest.raises(ValueError, match='转正式|converted'):
  600. update_presign_order(
  601. db,
  602. order_id='own',
  603. body=MarketPresignOrderBody.model_validate(
  604. {'orderStatus': 'converted', 'remark': 'after'}
  605. ),
  606. ctx=_ctx('specialist'),
  607. user_info=_user(),
  608. )
  609. db.expire_all()
  610. unchanged = db.query(BizPresignOrder).filter_by(id='own').one()
  611. assert unchanged.order_status == current_status
  612. assert unchanged.remark == 'before'
  613. def test_update_rolls_back_when_response_serialization_fails(monkeypatch):
  614. from app.crud import presign_order as presign_crud
  615. from app.schemas.market_mobile import MarketPresignOrderBody
  616. from app.services.market_presign import update_presign_order
  617. db = _session()
  618. _add_master_data(db)
  619. order = _order('own', sales_user_id='u1')
  620. order.remark = 'before'
  621. db.add(order)
  622. db.commit()
  623. monkeypatch.setattr(
  624. presign_crud,
  625. 'order_to_dict',
  626. lambda *args, **kwargs: (_ for _ in ()).throw(
  627. RuntimeError('serialize failed')
  628. ),
  629. )
  630. with pytest.raises(RuntimeError, match='serialize failed'):
  631. update_presign_order(
  632. db,
  633. order_id='own',
  634. body=MarketPresignOrderBody(remark='after'),
  635. ctx=_ctx('specialist'),
  636. user_info=_user(),
  637. )
  638. db.expire_all()
  639. assert db.query(BizPresignOrder).filter_by(id='own').one().remark == 'before'
  640. def test_create_rolls_back_order_children_and_transfers_when_data_scope_rejects():
  641. from app.schemas.market_mobile import MarketPresignOrderBody
  642. from app.services.market_presign import create_presign_order
  643. db = _session()
  644. _add_master_data(db)
  645. restricted_user = {
  646. "user_id": "u1",
  647. "tenant_no": "tenant-a",
  648. "role": "ordinary",
  649. "dept_id": "dept-a",
  650. }
  651. with pytest.raises(HTTPException):
  652. create_presign_order(
  653. db,
  654. body=MarketPresignOrderBody.model_validate(
  655. {
  656. "regionId": "region-a",
  657. "hospitalId": "hospital-a",
  658. "sampleTypeIds": ["sample-a"],
  659. "transfers": [{"transferAmount": 100}],
  660. }
  661. ),
  662. ctx=_ctx("specialist"),
  663. user_info=restricted_user,
  664. )
  665. assert db.query(BizPresignOrder).count() == 0
  666. assert db.query(BizPresignOrderSampleType).count() == 0
  667. assert db.query(BizPresignTransfer).count() == 0
  668. def test_create_uses_real_auth_region_id_as_department_for_all_rows():
  669. from app.schemas.market_mobile import MarketPresignOrderBody
  670. from app.services.market_presign import create_presign_order
  671. db = _session()
  672. _add_master_data(db)
  673. data = create_presign_order(
  674. db,
  675. body=MarketPresignOrderBody.model_validate(
  676. {
  677. "regionId": "region-a",
  678. "hospitalId": "hospital-a",
  679. "sampleTypeIds": ["sample-a"],
  680. "transfers": [{"transferAmount": 100}],
  681. }
  682. ),
  683. ctx=_ctx("specialist"),
  684. user_info=_real_auth_user(),
  685. )
  686. assert db.query(BizPresignOrder).filter_by(id=data["id"]).one().create_dept_id == "dept-from-auth"
  687. assert db.query(BizPresignOrderSampleType).one().create_dept_id == "dept-from-auth"
  688. assert db.query(BizPresignTransfer).one().create_dept_id == "dept-from-auth"
  689. @pytest.mark.parametrize("user_id", ["cross-user", "deleted-user", "disabled-user"])
  690. def test_create_rejects_cross_tenant_deleted_or_disabled_person(user_id):
  691. from app.schemas.market_mobile import MarketPresignOrderBody
  692. from app.services.market_presign import create_presign_order
  693. db = _session()
  694. _add_master_data(db)
  695. with pytest.raises(ValueError, match="用户"):
  696. create_presign_order(
  697. db,
  698. body=MarketPresignOrderBody.model_validate(
  699. {"regionId": "region-a", "collectorUserId": user_id}
  700. ),
  701. ctx=_ctx("specialist"),
  702. user_info=_user(),
  703. )
  704. assert db.query(BizPresignOrder).count() == 0
  705. def test_manager_create_cannot_self_assign_an_unmanaged_region():
  706. from app.schemas.market_mobile import MarketPresignOrderBody
  707. from app.services.market_presign import create_presign_order
  708. db = _session()
  709. _add_master_data(db)
  710. db.add(
  711. BizCollectionRegionStaff(
  712. id='manager-region-a',
  713. region_id='region-a',
  714. user_id='u1',
  715. is_leader=1,
  716. tenant_no='tenant-a',
  717. create_dept_id='dept-a',
  718. del_flag=0,
  719. )
  720. )
  721. db.commit()
  722. with pytest.raises(ValueError, match="地区经理范围"):
  723. create_presign_order(
  724. db,
  725. body=MarketPresignOrderBody.model_validate(
  726. {
  727. "motherName": "越权订单",
  728. "regionId": "region-b",
  729. "regionManagerUserId": "u1",
  730. "sampleTypeIds": ["sample-a"],
  731. }
  732. ),
  733. ctx=_ctx("manager"),
  734. user_info=_user(),
  735. )
  736. assert db.query(BizPresignOrder).count() == 0
  737. @pytest.mark.parametrize(
  738. 'payload',
  739. [
  740. {'sampleTypeIds': ['sample-a']},
  741. {'motherName': ' ', 'sampleTypeIds': ['sample-a']},
  742. {'motherName': '测试客户'},
  743. {'motherName': '测试客户', 'sampleTypeIds': []},
  744. ],
  745. )
  746. def test_create_requires_mother_name_and_sample_types(payload):
  747. from pydantic import ValidationError
  748. from app.schemas.market_mobile import MarketPresignOrderCreateBody
  749. with pytest.raises(ValidationError, match='motherName|sampleTypeIds|母亲姓名'):
  750. MarketPresignOrderCreateBody.model_validate(payload)
  751. @pytest.mark.parametrize(
  752. ("payload", "message"),
  753. [
  754. ({"regionId": "region-cross"}, "采集地区"),
  755. ({"regionId": "region-missing"}, "采集地区"),
  756. (
  757. {"regionId": "region-a", "hospitalId": "hospital-b"},
  758. "医院",
  759. ),
  760. (
  761. {"regionId": "region-a", "sampleTypeIds": ["sample-cross"]},
  762. "样本类型",
  763. ),
  764. (
  765. {"regionId": "region-a", "sampleTypeIds": ["sample-missing"]},
  766. "样本类型",
  767. ),
  768. ],
  769. )
  770. def test_create_rejects_invalid_or_cross_tenant_master_data(payload, message):
  771. from app.schemas.market_mobile import MarketPresignOrderBody
  772. from app.services.market_presign import create_presign_order
  773. db = _session()
  774. _add_master_data(db)
  775. with pytest.raises(ValueError, match=message):
  776. create_presign_order(
  777. db,
  778. body=MarketPresignOrderBody.model_validate(payload),
  779. ctx=_ctx("specialist"),
  780. user_info=_user(),
  781. )
  782. assert db.query(BizPresignOrder).count() == 0
  783. def test_update_validates_resulting_hospital_region_and_sample_types_before_write():
  784. from app.schemas.market_mobile import MarketPresignOrderBody
  785. from app.services.market_presign import update_presign_order
  786. db = _session()
  787. _add_master_data(db)
  788. db.add(
  789. _order(
  790. "own",
  791. region_id="region-a",
  792. sales_user_id="u1",
  793. )
  794. )
  795. db.commit()
  796. with pytest.raises(ValueError, match="医院"):
  797. update_presign_order(
  798. db,
  799. order_id="own",
  800. body=MarketPresignOrderBody.model_validate(
  801. {"hospitalId": "hospital-b", "sampleTypeIds": ["sample-cross"]}
  802. ),
  803. ctx=_ctx("specialist"),
  804. user_info=_user(),
  805. )
  806. row = db.query(BizPresignOrder).filter_by(id="own").one()
  807. assert row.hospital_id is None
  808. assert db.query(BizPresignOrderSampleType).count() == 0
  809. def test_update_rebuilt_sample_types_keep_current_user_department():
  810. from app.schemas.market_mobile import MarketPresignOrderBody
  811. from app.services.market_presign import update_presign_order
  812. db = _session()
  813. _add_master_data(db)
  814. db.add(_order("own", region_id="region-a", sales_user_id="u1"))
  815. db.commit()
  816. update_presign_order(
  817. db,
  818. order_id="own",
  819. body=MarketPresignOrderBody.model_validate(
  820. {"sampleTypeIds": ["sample-a"]}
  821. ),
  822. ctx=_ctx("specialist"),
  823. user_info=_real_auth_user(),
  824. )
  825. child = db.query(BizPresignOrderSampleType).one()
  826. assert child.create_dept_id == "dept-from-auth"
  827. def test_update_rejects_disabled_person_before_write():
  828. from app.schemas.market_mobile import MarketPresignOrderBody
  829. from app.services.market_presign import update_presign_order
  830. db = _session()
  831. _add_master_data(db)
  832. db.add(_order("own", region_id="region-a", sales_user_id="u1"))
  833. db.commit()
  834. with pytest.raises(ValueError, match="用户"):
  835. update_presign_order(
  836. db,
  837. order_id="own",
  838. body=MarketPresignOrderBody.model_validate(
  839. {"collectorUserId": "disabled-user"}
  840. ),
  841. ctx=_ctx("specialist"),
  842. user_info=_user(),
  843. )
  844. assert db.query(BizPresignOrder).filter_by(id="own").one().collector_user_id is None
  845. def test_update_rejects_resulting_assignment_outside_manager_scope():
  846. from app.schemas.market_mobile import MarketPresignOrderBody
  847. from app.services.market_presign import update_presign_order
  848. db = _session()
  849. _add_master_data(db)
  850. db.add(_order("managed", region_id="region-a", manager_user_id="u1"))
  851. db.commit()
  852. with pytest.raises(ValueError, match="地区经理范围"):
  853. update_presign_order(
  854. db,
  855. order_id="managed",
  856. body=MarketPresignOrderBody.model_validate(
  857. {
  858. "regionId": "region-b",
  859. "regionManagerUserId": "u2",
  860. }
  861. ),
  862. ctx=_ctx("manager"),
  863. user_info=_user(),
  864. )
  865. row = db.query(BizPresignOrder).filter_by(id="managed").one()
  866. assert row.region_id == "region-a"
  867. assert row.region_manager_user_id == "u1"
  868. def test_convert_rechecks_scope_before_reusing_presign_conversion(monkeypatch):
  869. from app.crud import presign_order as presign_crud
  870. from app.services.market_presign import convert_presign_order
  871. db = _session()
  872. _add_master_data(db)
  873. db.add_all(
  874. [
  875. _order("own", sales_user_id="u1", manager_user_id="manager-a"),
  876. _order("other", sales_user_id="u2", manager_user_id="manager-b"),
  877. ]
  878. )
  879. db.commit()
  880. calls = []
  881. def fake_convert(db, *, order_id, tenant_no, user_id, create_dept_id=""):
  882. calls.append((order_id, tenant_no, user_id, create_dept_id))
  883. return {"protocolId": "protocol-1"}
  884. monkeypatch.setattr(presign_crud, "convert_to_protocol", fake_convert)
  885. with pytest.raises(HTTPException) as exc_info:
  886. convert_presign_order(
  887. db,
  888. order_id="other",
  889. ctx=_ctx("specialist"),
  890. user_info=_user(),
  891. )
  892. assert exc_info.value.status_code == 404
  893. assert calls == []
  894. assert convert_presign_order(
  895. db,
  896. order_id="own",
  897. ctx=_ctx("specialist"),
  898. user_info=_real_auth_user(),
  899. ) == {"protocolId": "protocol-1"}
  900. assert calls == [("own", "tenant-a", "u1", "dept-from-auth")]
  901. def test_convert_rolls_back_protocol_children_and_presign_status_on_midway_failure(
  902. monkeypatch,
  903. ):
  904. from app.crud import collection_protocol as protocol_crud
  905. from app.services.market_presign import convert_presign_order
  906. db = _session()
  907. _add_master_data(db)
  908. order = _order(
  909. "own",
  910. region_id="region-a",
  911. sales_user_id="u1",
  912. manager_user_id="manager-a",
  913. )
  914. order.protocol_no = "P-ATOMIC-1"
  915. order.hospital_id = "hospital-a"
  916. db.add(order)
  917. db.add(
  918. BizPresignOrderSampleType(
  919. id="presign-sample",
  920. presign_order_id="own",
  921. sample_type_id="sample-a",
  922. tenant_no="tenant-a",
  923. create_dept_id="dept-a",
  924. del_flag=0,
  925. )
  926. )
  927. db.commit()
  928. def fail_after_collection(*args, **kwargs):
  929. raise RuntimeError("injected sample failure")
  930. monkeypatch.setattr(protocol_crud, "save_samples", fail_after_collection)
  931. with pytest.raises(RuntimeError, match="injected sample failure"):
  932. convert_presign_order(
  933. db,
  934. order_id="own",
  935. ctx=_ctx("specialist"),
  936. user_info=_user(),
  937. )
  938. db.expire_all()
  939. unchanged = db.query(BizPresignOrder).filter_by(id="own").one()
  940. assert unchanged.order_status == "active"
  941. assert unchanged.converted_protocol_id is None
  942. assert db.query(BizCollectionProtocol).count() == 0
  943. assert db.query(BizProtocolPartyCustomer).count() == 0
  944. assert db.query(BizProtocolCollectionInfo).count() == 0
  945. assert db.query(BizProtocolSample).count() == 0
  946. def test_convert_creates_protocol_in_effective_status():
  947. """预签约转正式后,正式协议状态应为生效中(sxz)。"""
  948. from app.services.market_presign import convert_presign_order
  949. db = _session()
  950. _add_master_data(db)
  951. order = _order(
  952. "own",
  953. region_id="region-a",
  954. sales_user_id="u1",
  955. manager_user_id="manager-a",
  956. )
  957. order.protocol_no = "P-CONVERT-SXZ"
  958. order.hospital_id = "hospital-a"
  959. db.add(order)
  960. db.commit()
  961. result = convert_presign_order(
  962. db,
  963. order_id="own",
  964. ctx=_ctx("specialist"),
  965. user_info=_user(),
  966. )
  967. protocol = db.query(BizCollectionProtocol).filter_by(id=result["protocolId"]).one()
  968. assert protocol.protocol_status == "sxz"
  969. def test_convert_rejects_invalid_person_before_creating_protocol():
  970. from app.services.market_presign import convert_presign_order
  971. db = _session()
  972. _add_master_data(db)
  973. order = _order("own", sales_user_id="u1")
  974. order.protocol_no = "P-INVALID-USER"
  975. order.collector_user_id = "cross-user"
  976. db.add(order)
  977. db.commit()
  978. with pytest.raises(ValueError, match="用户"):
  979. convert_presign_order(
  980. db,
  981. order_id="own",
  982. ctx=_ctx("specialist"),
  983. user_info=_user(),
  984. )
  985. assert db.query(BizCollectionProtocol).count() == 0
  986. def test_convert_fetches_presign_row_with_for_update(monkeypatch):
  987. from app.crud import presign_order as presign_crud
  988. class QueryProbe:
  989. def __init__(self):
  990. self.locked = False
  991. def filter(self, *args):
  992. return self
  993. def with_for_update(self):
  994. self.locked = True
  995. return self
  996. def populate_existing(self):
  997. return self
  998. def first(self):
  999. return object()
  1000. class DbProbe:
  1001. def __init__(self):
  1002. self.query_probe = QueryProbe()
  1003. def query(self, model):
  1004. return self.query_probe
  1005. db = DbProbe()
  1006. assert presign_crud._get_order_or_raise(
  1007. db, order_id="order-1", tenant_no="tenant-a", for_update=True
  1008. ) is not None
  1009. assert db.query_probe.locked is True
  1010. def test_locked_convert_read_refreshes_preloaded_stale_identity(tmp_path):
  1011. from app.crud import presign_order as presign_crud
  1012. engine = create_engine(f"sqlite:///{tmp_path / 'stale.db'}")
  1013. Base.metadata.create_all(engine, tables=[BizPresignOrder.__table__])
  1014. Session = sessionmaker(bind=engine)
  1015. first = Session()
  1016. second = Session()
  1017. try:
  1018. first.add(_order("race", sales_user_id="u1"))
  1019. first.commit()
  1020. cached = first.query(BizPresignOrder).filter_by(id="race").one()
  1021. assert cached.order_status == "active"
  1022. concurrent = second.query(BizPresignOrder).filter_by(id="race").one()
  1023. concurrent.order_status = "converted"
  1024. concurrent.converted_protocol_id = "protocol-existing"
  1025. second.commit()
  1026. locked = presign_crud._get_order_or_raise(
  1027. first,
  1028. order_id="race",
  1029. tenant_no="tenant-a",
  1030. for_update=True,
  1031. )
  1032. assert locked is cached
  1033. assert locked.order_status == "converted"
  1034. assert locked.converted_protocol_id == "protocol-existing"
  1035. with pytest.raises(ValueError, match="已转正式"):
  1036. presign_crud.assert_presign_convertible(
  1037. order_status=locked.order_status
  1038. )
  1039. finally:
  1040. first.close()
  1041. second.close()
  1042. def test_convert_translates_unique_race_to_already_converted_domain_error(
  1043. monkeypatch,
  1044. ):
  1045. from app.crud import collection_protocol as protocol_crud
  1046. from app.services.market_presign import convert_presign_order
  1047. db = _session()
  1048. _add_master_data(db)
  1049. order = _order("own", sales_user_id="u1", manager_user_id="manager-a")
  1050. order.protocol_no = "P-RACE"
  1051. db.add(order)
  1052. db.commit()
  1053. def duplicate_source(*args, **kwargs):
  1054. raise IntegrityError(
  1055. "INSERT INTO biz_collection_protocol",
  1056. {},
  1057. Exception("Duplicate entry for key 'uk_collection_protocol_presign_source'"),
  1058. )
  1059. monkeypatch.setattr(
  1060. protocol_crud, "create_collection_protocol", duplicate_source
  1061. )
  1062. with pytest.raises(ValueError, match="已转正式"):
  1063. convert_presign_order(
  1064. db,
  1065. order_id="own",
  1066. ctx=_ctx("specialist"),
  1067. user_info=_user(),
  1068. )
  1069. assert db.query(BizPresignOrder).filter_by(id="own").one().order_status == "active"
  1070. def test_market_presign_bodies_forbid_unknown_fields_and_validate_transfer_limits():
  1071. from pydantic import ValidationError
  1072. from app.schemas.market_mobile import (
  1073. MarketPresignOrderBody,
  1074. MarketPresignTransferBody,
  1075. )
  1076. with pytest.raises(ValidationError, match='tenantNo'):
  1077. MarketPresignOrderBody.model_validate({'tenantNo': 'tenant-b'})
  1078. assert (
  1079. MarketPresignOrderBody.model_validate(
  1080. {'orderStatus': 'void'}
  1081. ).order_status
  1082. == 'void'
  1083. )
  1084. valid = MarketPresignTransferBody.model_validate(
  1085. {
  1086. 'transferAmount': '123.45',
  1087. 'transferTime': '2026-10-11 12:34:56',
  1088. 'transferType': 'bank',
  1089. 'transferAttachments': 'a' * 4000,
  1090. 'remark': 'r' * 512,
  1091. }
  1092. )
  1093. assert str(valid.transfer_amount) == '123.45'
  1094. assert valid.transfer_time == '2026-10-11 12:34:56'
  1095. assert (
  1096. MarketPresignTransferBody.model_validate(
  1097. {'transferTime': '2026-10-11T12:34'}
  1098. ).transfer_time
  1099. == '2026-10-11T12:34'
  1100. )
  1101. for payload in (
  1102. {'unknown': 'value'},
  1103. {'transferAmount': 0},
  1104. {'transferTime': 'not-a-datetime'},
  1105. {'transferTime': '2026-10-11'},
  1106. {'transferTime': '2026-10-11 12:34'},
  1107. {'transferTime': '2026-10-11T12'},
  1108. {'transferTime': '2026-10-11T12:34+08:00'},
  1109. {'transferType': 'x' * 17},
  1110. {'transferAttachments': 'x' * 4001},
  1111. {'remark': 'x' * 513},
  1112. ):
  1113. with pytest.raises(ValidationError):
  1114. MarketPresignTransferBody.model_validate(payload)
  1115. def test_market_presign_order_body_accepts_database_sized_values():
  1116. from decimal import Decimal
  1117. from app.schemas.market_mobile import MarketPresignOrderBody
  1118. body = MarketPresignOrderBody.model_validate(
  1119. {
  1120. 'orderDate': '20261011',
  1121. 'protocolNo': 'P' * 64,
  1122. 'motherName': '母' * 64,
  1123. 'expectedDeliveryDate': '20261231',
  1124. 'regionId': 'r' * 64,
  1125. 'hospitalId': 'h' * 64,
  1126. 'sampleCollectTime': '2026-10-11 12:34:56',
  1127. 'payMode': 'x' * 8,
  1128. 'contractAmount': '9999999999999999.99',
  1129. 'collectorUserId': 'c' * 64,
  1130. 'regionManagerUserId': 'm' * 64,
  1131. 'remark': '备' * 512,
  1132. 'sampleTypeIds': [f'sample-{index}' for index in range(100)],
  1133. }
  1134. )
  1135. assert body.order_date == '20261011'
  1136. assert body.expected_delivery_date == '20261231'
  1137. assert body.contract_amount == Decimal('9999999999999999.99')
  1138. assert len(body.sample_type_ids or []) == 100
  1139. @pytest.mark.parametrize(
  1140. 'payload',
  1141. [
  1142. {'orderDate': '2026-10-11'},
  1143. {'orderDate': '20260230'},
  1144. {'expectedDeliveryDate': '20261301'},
  1145. {'sampleCollectTime': 'not-a-datetime'},
  1146. {'protocolNo': 'p' * 65},
  1147. {'motherName': 'm' * 65},
  1148. {'regionId': 'r' * 65},
  1149. {'hospitalId': 'h' * 65},
  1150. {'collectorUserId': 'c' * 65},
  1151. {'salesUserId': 's' * 65},
  1152. {'regionManagerUserId': 'm' * 65},
  1153. {'payMode': 'p' * 9},
  1154. {'remark': 'r' * 513},
  1155. {'sampleTypeIds': ['s' * 65]},
  1156. {'sampleTypeIds': [f'sample-{index}' for index in range(101)]},
  1157. {'contractAmount': 'NaN'},
  1158. {'contractAmount': 'Infinity'},
  1159. {'contractAmount': '-0.01'},
  1160. {'contractAmount': '10000000000000000.00'},
  1161. {'contractAmount': '1.001'},
  1162. ],
  1163. )
  1164. def test_market_presign_order_body_rejects_values_outside_database_bounds(
  1165. payload,
  1166. ):
  1167. from pydantic import ValidationError
  1168. from app.schemas.market_mobile import MarketPresignOrderBody
  1169. with pytest.raises(ValidationError):
  1170. MarketPresignOrderBody.model_validate(payload)
  1171. @pytest.mark.parametrize('status', ['active', 'void'])
  1172. def test_specialist_can_soft_delete_own_non_converted_presign(status):
  1173. from app.services.market_presign import delete_presign_order
  1174. db = _session()
  1175. order = _order('own', sales_user_id='u1')
  1176. order.order_status = status
  1177. db.add(order)
  1178. db.commit()
  1179. delete_presign_order(
  1180. db,
  1181. order_id='own',
  1182. ctx=_ctx('specialist'),
  1183. user_info=_user(),
  1184. )
  1185. deleted = db.query(BizPresignOrder).filter_by(id='own').one()
  1186. assert deleted.del_flag == 1
  1187. assert deleted.update_by == 'u1'
  1188. def test_specialist_cannot_delete_another_specialists_presign():
  1189. from app.services.market_presign import delete_presign_order
  1190. db = _session()
  1191. db.add(_order('other', sales_user_id='u2'))
  1192. db.commit()
  1193. with pytest.raises(HTTPException) as exc_info:
  1194. delete_presign_order(
  1195. db,
  1196. order_id='other',
  1197. ctx=_ctx('specialist'),
  1198. user_info=_user(),
  1199. )
  1200. assert exc_info.value.status_code == 404
  1201. assert db.query(BizPresignOrder).filter_by(id='other').one().del_flag == 0
  1202. def test_manager_delete_is_limited_to_manager_scope():
  1203. from app.services.market_presign import delete_presign_order
  1204. db = _session()
  1205. db.add(
  1206. BizCollectionRegionStaff(
  1207. id='manager-region',
  1208. region_id='region-led',
  1209. user_id='u1',
  1210. is_leader=1,
  1211. tenant_no='tenant-a',
  1212. create_dept_id='dept-a',
  1213. del_flag=0,
  1214. )
  1215. )
  1216. db.add_all(
  1217. [
  1218. _order(
  1219. 'managed',
  1220. region_id='region-led',
  1221. sales_user_id='u2',
  1222. manager_user_id='u2',
  1223. ),
  1224. _order(
  1225. 'outside',
  1226. region_id='region-other',
  1227. sales_user_id='u2',
  1228. manager_user_id='u2',
  1229. ),
  1230. ]
  1231. )
  1232. db.commit()
  1233. delete_presign_order(
  1234. db,
  1235. order_id='managed',
  1236. ctx=_ctx('manager'),
  1237. user_info=_user(),
  1238. )
  1239. with pytest.raises(HTTPException) as exc_info:
  1240. delete_presign_order(
  1241. db,
  1242. order_id='outside',
  1243. ctx=_ctx('manager'),
  1244. user_info=_user(),
  1245. )
  1246. assert exc_info.value.status_code == 404
  1247. assert db.query(BizPresignOrder).filter_by(id='managed').one().del_flag == 1
  1248. assert db.query(BizPresignOrder).filter_by(id='outside').one().del_flag == 0
  1249. def test_converted_presign_cannot_be_deleted():
  1250. from app.services.market_presign import delete_presign_order
  1251. db = _session()
  1252. order = _order('converted', sales_user_id='u1')
  1253. order.order_status = 'converted'
  1254. db.add(order)
  1255. db.commit()
  1256. with pytest.raises(ValueError, match='已转|converted'):
  1257. delete_presign_order(
  1258. db,
  1259. order_id='converted',
  1260. ctx=_ctx('specialist'),
  1261. user_info=_user(),
  1262. )
  1263. assert db.query(BizPresignOrder).filter_by(id='converted').one().del_flag == 0
  1264. @pytest.mark.parametrize('scope_kind', ['specialist', 'manager'])
  1265. @pytest.mark.parametrize(
  1266. ('action', 'pay_status'),
  1267. [
  1268. ('create', 'dfj'),
  1269. ('update', 'dfj'),
  1270. ('delete', 'dfj'),
  1271. ('submit', 'dfj'),
  1272. ('withdraw', 'jfqrz'),
  1273. ],
  1274. )
  1275. def test_presign_transfer_writes_recheck_parent_order_scope(
  1276. scope_kind, action, pay_status
  1277. ):
  1278. from app.schemas.market_mobile import MarketPresignTransferBody
  1279. from app.services.market_presign import (
  1280. create_presign_transfer,
  1281. delete_presign_transfer,
  1282. submit_presign_transfer,
  1283. update_presign_transfer,
  1284. withdraw_presign_transfer,
  1285. )
  1286. db = _session()
  1287. db.add(
  1288. _order(
  1289. 'outside',
  1290. region_id='region-outside',
  1291. sales_user_id='u2',
  1292. manager_user_id='u2',
  1293. )
  1294. )
  1295. db.add(_transfer('outside-transfer', order_id='outside', pay_status=pay_status))
  1296. db.commit()
  1297. body = MarketPresignTransferBody(
  1298. transferAmount=200,
  1299. transferType='bank',
  1300. )
  1301. calls = {
  1302. 'create': lambda: create_presign_transfer(
  1303. db,
  1304. order_id='outside',
  1305. body=body,
  1306. ctx=_ctx(scope_kind),
  1307. user_info=_user(),
  1308. ),
  1309. 'update': lambda: update_presign_transfer(
  1310. db,
  1311. transfer_id='outside-transfer',
  1312. body=body,
  1313. ctx=_ctx(scope_kind),
  1314. user_info=_user(),
  1315. ),
  1316. 'delete': lambda: delete_presign_transfer(
  1317. db,
  1318. transfer_id='outside-transfer',
  1319. ctx=_ctx(scope_kind),
  1320. user_info=_user(),
  1321. ),
  1322. 'submit': lambda: submit_presign_transfer(
  1323. db,
  1324. transfer_id='outside-transfer',
  1325. ctx=_ctx(scope_kind),
  1326. user_info=_user(),
  1327. ),
  1328. 'withdraw': lambda: withdraw_presign_transfer(
  1329. db,
  1330. transfer_id='outside-transfer',
  1331. ctx=_ctx(scope_kind),
  1332. user_info=_user(),
  1333. ),
  1334. }
  1335. with pytest.raises(HTTPException) as exc_info:
  1336. calls[action]()
  1337. assert exc_info.value.status_code == 404
  1338. row = db.query(BizPresignTransfer).filter_by(id='outside-transfer').one()
  1339. assert row.del_flag == 0
  1340. assert row.pay_status == pay_status
  1341. def test_dfj_presign_transfer_can_edit_submit_withdraw_and_soft_delete():
  1342. from app.schemas.market_mobile import MarketPresignTransferBody
  1343. from app.services.market_presign import (
  1344. create_presign_transfer,
  1345. delete_presign_transfer,
  1346. submit_presign_transfer,
  1347. update_presign_transfer,
  1348. withdraw_presign_transfer,
  1349. )
  1350. db = _session()
  1351. db.add(_order('own', sales_user_id='u1'))
  1352. db.commit()
  1353. created = create_presign_transfer(
  1354. db,
  1355. order_id='own',
  1356. body=MarketPresignTransferBody(
  1357. transferAmount='100.50',
  1358. transferType='bank',
  1359. ),
  1360. ctx=_ctx('specialist'),
  1361. user_info=_real_auth_user(),
  1362. )
  1363. transfer_id = created['id']
  1364. updated = update_presign_transfer(
  1365. db,
  1366. transfer_id=transfer_id,
  1367. body=MarketPresignTransferBody(transferAmount='200.25', remark='更新'),
  1368. ctx=_ctx('specialist'),
  1369. user_info=_user(),
  1370. )
  1371. assert updated['transferAmount'] == '200.25'
  1372. assert updated['remark'] == '更新'
  1373. assert (
  1374. db.query(BizPresignTransfer).filter_by(id=transfer_id).one().create_dept_id
  1375. == 'dept-from-auth'
  1376. )
  1377. submitted = submit_presign_transfer(
  1378. db,
  1379. transfer_id=transfer_id,
  1380. ctx=_ctx('specialist'),
  1381. user_info=_user(),
  1382. )
  1383. assert submitted['payStatus'] == 'jfqrz'
  1384. with pytest.raises(ValueError, match='状态|待缴费|已缴费|已付款'):
  1385. delete_presign_transfer(
  1386. db,
  1387. transfer_id=transfer_id,
  1388. ctx=_ctx('specialist'),
  1389. user_info=_user(),
  1390. )
  1391. withdrawn = withdraw_presign_transfer(
  1392. db,
  1393. transfer_id=transfer_id,
  1394. ctx=_ctx('specialist'),
  1395. user_info=_user(),
  1396. )
  1397. assert withdrawn['payStatus'] == 'dfj'
  1398. delete_presign_transfer(
  1399. db,
  1400. transfer_id=transfer_id,
  1401. ctx=_ctx('specialist'),
  1402. user_info=_user(),
  1403. )
  1404. deleted = db.query(BizPresignTransfer).filter_by(id=transfer_id).one()
  1405. assert deleted.del_flag == 1
  1406. assert deleted.update_by == 'u1'
  1407. def test_jfqrz_presign_transfer_only_allows_withdraw():
  1408. from app.schemas.market_mobile import MarketPresignTransferBody
  1409. from app.services.market_presign import (
  1410. delete_presign_transfer,
  1411. submit_presign_transfer,
  1412. update_presign_transfer,
  1413. withdraw_presign_transfer,
  1414. )
  1415. db = _session()
  1416. db.add(_order('own', sales_user_id='u1'))
  1417. db.add(_transfer('pending', pay_status='jfqrz'))
  1418. db.commit()
  1419. for action in (
  1420. lambda: update_presign_transfer(
  1421. db,
  1422. transfer_id='pending',
  1423. body=MarketPresignTransferBody(remark='不允许'),
  1424. ctx=_ctx('specialist'),
  1425. user_info=_user(),
  1426. ),
  1427. lambda: delete_presign_transfer(
  1428. db,
  1429. transfer_id='pending',
  1430. ctx=_ctx('specialist'),
  1431. user_info=_user(),
  1432. ),
  1433. lambda: submit_presign_transfer(
  1434. db,
  1435. transfer_id='pending',
  1436. ctx=_ctx('specialist'),
  1437. user_info=_user(),
  1438. ),
  1439. ):
  1440. with pytest.raises(ValueError):
  1441. action()
  1442. assert withdraw_presign_transfer(
  1443. db,
  1444. transfer_id='pending',
  1445. ctx=_ctx('specialist'),
  1446. user_info=_user(),
  1447. )['payStatus'] == 'dfj'
  1448. @pytest.mark.parametrize('action', ['update', 'delete', 'submit', 'withdraw'])
  1449. def test_yjf_presign_transfer_rejects_all_market_writes(action):
  1450. from app.schemas.market_mobile import MarketPresignTransferBody
  1451. from app.services.market_presign import (
  1452. delete_presign_transfer,
  1453. submit_presign_transfer,
  1454. update_presign_transfer,
  1455. withdraw_presign_transfer,
  1456. )
  1457. db = _session()
  1458. db.add(_order('own', sales_user_id='u1'))
  1459. db.add(_transfer('paid', pay_status='yjf'))
  1460. db.commit()
  1461. calls = {
  1462. 'update': lambda: update_presign_transfer(
  1463. db,
  1464. transfer_id='paid',
  1465. body=MarketPresignTransferBody(remark='不允许'),
  1466. ctx=_ctx('specialist'),
  1467. user_info=_user(),
  1468. ),
  1469. 'delete': lambda: delete_presign_transfer(
  1470. db,
  1471. transfer_id='paid',
  1472. ctx=_ctx('specialist'),
  1473. user_info=_user(),
  1474. ),
  1475. 'submit': lambda: submit_presign_transfer(
  1476. db,
  1477. transfer_id='paid',
  1478. ctx=_ctx('specialist'),
  1479. user_info=_user(),
  1480. ),
  1481. 'withdraw': lambda: withdraw_presign_transfer(
  1482. db,
  1483. transfer_id='paid',
  1484. ctx=_ctx('specialist'),
  1485. user_info=_user(),
  1486. ),
  1487. }
  1488. with pytest.raises(ValueError):
  1489. calls[action]()
  1490. @pytest.mark.parametrize('order_status', ['converted', 'void'])
  1491. @pytest.mark.parametrize('action', ['create', 'update', 'delete', 'submit', 'withdraw'])
  1492. def test_non_writable_presign_order_rejects_every_transfer_write(
  1493. order_status, action
  1494. ):
  1495. from app.schemas.market_mobile import MarketPresignTransferBody
  1496. from app.services.market_presign import (
  1497. create_presign_transfer,
  1498. delete_presign_transfer,
  1499. submit_presign_transfer,
  1500. update_presign_transfer,
  1501. withdraw_presign_transfer,
  1502. )
  1503. db = _session()
  1504. order = _order('own', sales_user_id='u1')
  1505. order.order_status = order_status
  1506. db.add(order)
  1507. db.add(
  1508. _transfer(
  1509. 'transfer',
  1510. pay_status='jfqrz' if action == 'withdraw' else 'dfj',
  1511. )
  1512. )
  1513. db.commit()
  1514. body = MarketPresignTransferBody(
  1515. transferAmount=100,
  1516. transferType='bank',
  1517. )
  1518. calls = {
  1519. 'create': lambda: create_presign_transfer(
  1520. db,
  1521. order_id='own',
  1522. body=body,
  1523. ctx=_ctx('specialist'),
  1524. user_info=_user(),
  1525. ),
  1526. 'update': lambda: update_presign_transfer(
  1527. db,
  1528. transfer_id='transfer',
  1529. body=body,
  1530. ctx=_ctx('specialist'),
  1531. user_info=_user(),
  1532. ),
  1533. 'delete': lambda: delete_presign_transfer(
  1534. db,
  1535. transfer_id='transfer',
  1536. ctx=_ctx('specialist'),
  1537. user_info=_user(),
  1538. ),
  1539. 'submit': lambda: submit_presign_transfer(
  1540. db,
  1541. transfer_id='transfer',
  1542. ctx=_ctx('specialist'),
  1543. user_info=_user(),
  1544. ),
  1545. 'withdraw': lambda: withdraw_presign_transfer(
  1546. db,
  1547. transfer_id='transfer',
  1548. ctx=_ctx('specialist'),
  1549. user_info=_user(),
  1550. ),
  1551. }
  1552. with pytest.raises(ValueError, match='订单|状态|转账'):
  1553. calls[action]()
  1554. @pytest.mark.parametrize('action', ['create', 'update'])
  1555. def test_presign_transfer_rejects_unregistered_attachment_url(action):
  1556. from app.schemas.market_mobile import MarketPresignTransferBody
  1557. from app.services.market_presign import (
  1558. create_presign_transfer,
  1559. update_presign_transfer,
  1560. )
  1561. db = _session()
  1562. db.add(_order('own', sales_user_id='u1'))
  1563. db.add(_transfer('draft'))
  1564. db.commit()
  1565. body = MarketPresignTransferBody(
  1566. transferAttachments='https://evil.test/proof.pdf'
  1567. )
  1568. with pytest.raises(ValueError, match='附件'):
  1569. if action == 'create':
  1570. create_presign_transfer(
  1571. db,
  1572. order_id='own',
  1573. body=body,
  1574. ctx=_ctx('specialist'),
  1575. user_info=_user(),
  1576. )
  1577. else:
  1578. update_presign_transfer(
  1579. db,
  1580. transfer_id='draft',
  1581. body=body,
  1582. ctx=_ctx('specialist'),
  1583. user_info=_user(),
  1584. )
  1585. def test_presign_transfer_attachments_bind_to_parent_order():
  1586. from app.schemas.market_mobile import MarketPresignTransferBody
  1587. from app.services.market_presign import (
  1588. create_presign_transfer,
  1589. update_presign_transfer,
  1590. )
  1591. db = _session()
  1592. db.add(_order('own', sales_user_id='u1'))
  1593. db.add_all(
  1594. [
  1595. BizAttachment(
  1596. id='attachment-create',
  1597. business_type='market:specialist:presign:staging',
  1598. original_name='create.pdf',
  1599. file_url='presign/create.pdf',
  1600. create_by='u1',
  1601. create_dept_id='dept-a',
  1602. tenant_no='tenant-a',
  1603. del_flag=0,
  1604. ),
  1605. BizAttachment(
  1606. id='attachment-update',
  1607. business_type='market:specialist:presign:staging',
  1608. original_name='update.pdf',
  1609. file_url='presign/update.pdf',
  1610. create_by='u1',
  1611. create_dept_id='dept-a',
  1612. tenant_no='tenant-a',
  1613. del_flag=0,
  1614. ),
  1615. ]
  1616. )
  1617. db.commit()
  1618. created = create_presign_transfer(
  1619. db,
  1620. order_id='own',
  1621. body=MarketPresignTransferBody(
  1622. transferAmount=100,
  1623. transferType='bank',
  1624. transferAttachments='attachment-create',
  1625. ),
  1626. ctx=_ctx('specialist'),
  1627. user_info=_user(),
  1628. )
  1629. updated = update_presign_transfer(
  1630. db,
  1631. transfer_id=created['id'],
  1632. body=MarketPresignTransferBody(
  1633. transferAttachments='attachment-create,attachment-update'
  1634. ),
  1635. ctx=_ctx('specialist'),
  1636. user_info=_user(),
  1637. )
  1638. assert updated['transferAttachments'] == (
  1639. 'presign/create.pdf,presign/update.pdf'
  1640. )
  1641. attachments = db.query(BizAttachment).order_by(BizAttachment.id).all()
  1642. assert {row.business_id for row in attachments} == {'own'}
  1643. assert {row.business_type for row in attachments} == {
  1644. 'market:specialist:presign:bound'
  1645. }
  1646. def test_stale_withdraw_does_not_overwrite_later_finance_confirmation(tmp_path):
  1647. from app.crud import presign_order as presign_crud
  1648. from app.services.market_presign import withdraw_presign_transfer
  1649. Session = _session_factory(f"sqlite:///{tmp_path / 'withdraw-race.db'}")
  1650. market_db = Session()
  1651. finance_db = Session()
  1652. try:
  1653. market_db.add(_order('own', sales_user_id='u1'))
  1654. market_db.add(_transfer('transfer', pay_status='jfqrz'))
  1655. market_db.commit()
  1656. stale = market_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1657. assert stale.pay_status == 'jfqrz'
  1658. presign_crud.confirm_receipt(
  1659. finance_db,
  1660. transfer_id='transfer',
  1661. tenant_no='tenant-a',
  1662. user_id='finance-user',
  1663. )
  1664. with pytest.raises(ValueError, match='状态|撤回'):
  1665. withdraw_presign_transfer(
  1666. market_db,
  1667. transfer_id='transfer',
  1668. ctx=_ctx('specialist'),
  1669. user_info=_user(),
  1670. )
  1671. finance_db.expire_all()
  1672. current = finance_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1673. assert current.pay_status == 'yjf'
  1674. assert current.update_by == 'finance-user'
  1675. finally:
  1676. market_db.close()
  1677. finance_db.close()
  1678. def test_stale_finance_confirmation_does_not_overwrite_later_mobile_withdraw(
  1679. tmp_path,
  1680. ):
  1681. from app.crud import presign_order as presign_crud
  1682. from app.services.market_presign import withdraw_presign_transfer
  1683. Session = _session_factory(
  1684. f"sqlite:///{tmp_path / 'finance-confirm-after-withdraw-race.db'}"
  1685. )
  1686. finance_db = Session()
  1687. market_db = Session()
  1688. try:
  1689. finance_db.add(_order('own', sales_user_id='u1'))
  1690. finance_db.add(_transfer('transfer', pay_status='jfqrz'))
  1691. finance_db.commit()
  1692. stale = finance_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1693. assert stale.pay_status == 'jfqrz'
  1694. withdrawn = withdraw_presign_transfer(
  1695. market_db,
  1696. transfer_id='transfer',
  1697. ctx=_ctx('specialist'),
  1698. user_info=_user(),
  1699. )
  1700. assert withdrawn['payStatus'] == 'dfj'
  1701. with pytest.raises(ValueError, match='状态'):
  1702. presign_crud.confirm_receipt(
  1703. finance_db,
  1704. transfer_id='transfer',
  1705. tenant_no='tenant-a',
  1706. user_id='finance-user',
  1707. )
  1708. market_db.expire_all()
  1709. current = market_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1710. assert current.pay_status == 'dfj'
  1711. assert current.update_by == 'u1'
  1712. finally:
  1713. finance_db.close()
  1714. market_db.close()
  1715. @pytest.mark.parametrize('action', ['update', 'submit'])
  1716. def test_stale_web_draft_action_does_not_mutate_later_paid_transfer(
  1717. tmp_path, action
  1718. ):
  1719. from app.crud import presign_order as presign_crud
  1720. Session = _session_factory(
  1721. f"sqlite:///{tmp_path / f'web-{action}-after-paid-race.db'}"
  1722. )
  1723. web_db = Session()
  1724. finance_db = Session()
  1725. try:
  1726. web_db.add(_order('own', sales_user_id='u1'))
  1727. transfer = _transfer('transfer', pay_status='dfj')
  1728. transfer.remark = 'original'
  1729. web_db.add(transfer)
  1730. web_db.commit()
  1731. stale = web_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1732. assert stale.pay_status == 'dfj'
  1733. presign_crud.submit_receipt_finance(
  1734. finance_db,
  1735. transfer_id='transfer',
  1736. tenant_no='tenant-a',
  1737. user_id='finance-user',
  1738. )
  1739. presign_crud.confirm_receipt(
  1740. finance_db,
  1741. transfer_id='transfer',
  1742. tenant_no='tenant-a',
  1743. user_id='finance-user',
  1744. )
  1745. calls = {
  1746. 'update': lambda: presign_crud.update_transfer_draft(
  1747. web_db,
  1748. transfer_id='transfer',
  1749. tenant_no='tenant-a',
  1750. user_id='web-user',
  1751. fields={'remark': 'stale-write'},
  1752. ),
  1753. 'submit': lambda: presign_crud.submit_receipt_finance(
  1754. web_db,
  1755. transfer_id='transfer',
  1756. tenant_no='tenant-a',
  1757. user_id='web-user',
  1758. ),
  1759. }
  1760. with pytest.raises(ValueError, match='状态'):
  1761. calls[action]()
  1762. finance_db.expire_all()
  1763. current = finance_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1764. assert current.pay_status == 'yjf'
  1765. assert current.remark == 'original'
  1766. assert current.update_by == 'finance-user'
  1767. finally:
  1768. web_db.close()
  1769. finance_db.close()
  1770. @pytest.mark.parametrize('action', ['withdraw', 'confirm', 'reject'])
  1771. def test_stale_shared_pending_action_does_not_mutate_later_draft_transfer(
  1772. tmp_path, action
  1773. ):
  1774. from app.crud import presign_order as presign_crud
  1775. from app.services.market_presign import withdraw_presign_transfer
  1776. Session = _session_factory(
  1777. f"sqlite:///{tmp_path / f'shared-{action}-after-withdraw-race.db'}"
  1778. )
  1779. stale_db = Session()
  1780. market_db = Session()
  1781. try:
  1782. stale_db.add(_order('own', sales_user_id='u1'))
  1783. stale_db.add(_transfer('transfer', pay_status='jfqrz'))
  1784. stale_db.commit()
  1785. stale = stale_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1786. assert stale.pay_status == 'jfqrz'
  1787. withdraw_presign_transfer(
  1788. market_db,
  1789. transfer_id='transfer',
  1790. ctx=_ctx('specialist'),
  1791. user_info=_user(),
  1792. )
  1793. calls = {
  1794. 'withdraw': lambda: presign_crud.withdraw_receipt(
  1795. stale_db,
  1796. transfer_id='transfer',
  1797. tenant_no='tenant-a',
  1798. user_id='stale-user',
  1799. ),
  1800. 'confirm': lambda: presign_crud.confirm_receipt(
  1801. stale_db,
  1802. transfer_id='transfer',
  1803. tenant_no='tenant-a',
  1804. user_id='stale-user',
  1805. ),
  1806. 'reject': lambda: presign_crud.reject_receipt(
  1807. stale_db,
  1808. transfer_id='transfer',
  1809. tenant_no='tenant-a',
  1810. user_id='stale-user',
  1811. ),
  1812. }
  1813. with pytest.raises(ValueError, match='状态'):
  1814. calls[action]()
  1815. market_db.expire_all()
  1816. current = market_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1817. assert current.pay_status == 'dfj'
  1818. assert current.update_by == 'u1'
  1819. finally:
  1820. stale_db.close()
  1821. market_db.close()
  1822. def test_mobile_transfer_update_serializes_before_commit(monkeypatch):
  1823. from app.crud import presign_order as presign_crud
  1824. from app.schemas.market_mobile import MarketPresignTransferBody
  1825. from app.services.market_presign import update_presign_transfer
  1826. db = _session()
  1827. db.add(_order('own', sales_user_id='u1'))
  1828. db.add(_transfer('transfer', pay_status='dfj'))
  1829. db.commit()
  1830. committed = False
  1831. original_commit = db.commit
  1832. original_to_dict = presign_crud._transfer_to_dict
  1833. def tracked_commit():
  1834. nonlocal committed
  1835. committed = True
  1836. original_commit()
  1837. def snapshot_before_commit(*args, **kwargs):
  1838. assert not committed
  1839. return original_to_dict(*args, **kwargs)
  1840. monkeypatch.setattr(db, 'commit', tracked_commit)
  1841. monkeypatch.setattr(presign_crud, '_transfer_to_dict', snapshot_before_commit)
  1842. result = update_presign_transfer(
  1843. db,
  1844. transfer_id='transfer',
  1845. body=MarketPresignTransferBody(remark='updated'),
  1846. ctx=_ctx('specialist'),
  1847. user_info=_user(),
  1848. )
  1849. assert committed
  1850. assert result['remark'] == 'updated'
  1851. @pytest.mark.parametrize('action', ['update', 'delete', 'submit'])
  1852. def test_stale_dfj_action_does_not_mutate_later_paid_transfer(tmp_path, action):
  1853. from app.crud import presign_order as presign_crud
  1854. from app.schemas.market_mobile import MarketPresignTransferBody
  1855. from app.services.market_presign import (
  1856. delete_presign_transfer,
  1857. submit_presign_transfer,
  1858. update_presign_transfer,
  1859. )
  1860. Session = _session_factory(
  1861. f"sqlite:///{tmp_path / f'transfer-{action}-race.db'}"
  1862. )
  1863. market_db = Session()
  1864. finance_db = Session()
  1865. try:
  1866. market_db.add(_order('own', sales_user_id='u1'))
  1867. transfer = _transfer('transfer', pay_status='dfj')
  1868. transfer.remark = 'original'
  1869. market_db.add(transfer)
  1870. market_db.commit()
  1871. stale = market_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1872. assert stale.pay_status == 'dfj'
  1873. presign_crud.submit_receipt_finance(
  1874. finance_db,
  1875. transfer_id='transfer',
  1876. tenant_no='tenant-a',
  1877. user_id='finance-user',
  1878. )
  1879. presign_crud.confirm_receipt(
  1880. finance_db,
  1881. transfer_id='transfer',
  1882. tenant_no='tenant-a',
  1883. user_id='finance-user',
  1884. )
  1885. calls = {
  1886. 'update': lambda: update_presign_transfer(
  1887. market_db,
  1888. transfer_id='transfer',
  1889. body=MarketPresignTransferBody(remark='stale-write'),
  1890. ctx=_ctx('specialist'),
  1891. user_info=_user(),
  1892. ),
  1893. 'delete': lambda: delete_presign_transfer(
  1894. market_db,
  1895. transfer_id='transfer',
  1896. ctx=_ctx('specialist'),
  1897. user_info=_user(),
  1898. ),
  1899. 'submit': lambda: submit_presign_transfer(
  1900. market_db,
  1901. transfer_id='transfer',
  1902. ctx=_ctx('specialist'),
  1903. user_info=_user(),
  1904. ),
  1905. }
  1906. with pytest.raises(ValueError, match='状态|待缴费|提交'):
  1907. calls[action]()
  1908. finance_db.expire_all()
  1909. current = finance_db.query(BizPresignTransfer).filter_by(id='transfer').one()
  1910. assert current.pay_status == 'yjf'
  1911. assert current.del_flag == 0
  1912. assert current.remark == 'original'
  1913. finally:
  1914. market_db.close()
  1915. finance_db.close()
  1916. @pytest.mark.parametrize('action', ['update', 'delete'])
  1917. def test_stale_order_write_does_not_mutate_later_conversion(tmp_path, action):
  1918. from app.schemas.market_mobile import MarketPresignOrderBody
  1919. from app.services.market_presign import (
  1920. convert_presign_order,
  1921. delete_presign_order,
  1922. update_presign_order,
  1923. )
  1924. Session = _session_factory(
  1925. f"sqlite:///{tmp_path / f'order-{action}-race.db'}"
  1926. )
  1927. market_db = Session()
  1928. convert_db = Session()
  1929. try:
  1930. _add_master_data(market_db)
  1931. order = _order('own', sales_user_id='u1')
  1932. order.protocol_no = f'P-RACE-{action}'
  1933. market_db.add(order)
  1934. market_db.commit()
  1935. stale = market_db.query(BizPresignOrder).filter_by(id='own').one()
  1936. assert stale.order_status == 'active'
  1937. convert_presign_order(
  1938. convert_db,
  1939. order_id='own',
  1940. ctx=_ctx('specialist'),
  1941. user_info=_user(),
  1942. )
  1943. calls = {
  1944. 'update': lambda: update_presign_order(
  1945. market_db,
  1946. order_id='own',
  1947. body=MarketPresignOrderBody(remark='stale-write'),
  1948. ctx=_ctx('specialist'),
  1949. user_info=_user(),
  1950. ),
  1951. 'delete': lambda: delete_presign_order(
  1952. market_db,
  1953. order_id='own',
  1954. ctx=_ctx('specialist'),
  1955. user_info=_user(),
  1956. ),
  1957. }
  1958. with pytest.raises(ValueError, match='不可|已转'):
  1959. calls[action]()
  1960. convert_db.expire_all()
  1961. current = convert_db.query(BizPresignOrder).filter_by(id='own').one()
  1962. assert current.order_status == 'converted'
  1963. assert current.converted_protocol_id
  1964. assert current.del_flag == 0
  1965. assert current.remark is None
  1966. finally:
  1967. market_db.close()
  1968. convert_db.close()
  1969. def test_conversion_unique_index_migration_is_mysql57_idempotent():
  1970. from pathlib import Path
  1971. sql = (
  1972. Path(__file__).parents[2]
  1973. / "docs/sql/migrations/2026-10-10-presign-convert-unique.sql"
  1974. ).read_text(encoding="utf-8")
  1975. normalized = " ".join(sql.lower().replace("`", "").split())
  1976. assert "information_schema.statistics" in normalized
  1977. assert "create procedure ensure_presign_convert_unique" in normalized
  1978. assert "call ensure_presign_convert_unique()" in normalized
  1979. assert "group by tenant_no, source_presign_order_id having count(*) > 1" in normalized
  1980. assert "signal sqlstate '45000'" in normalized
  1981. assert "duplicate" in normalized
  1982. assert "non_unique" in normalized
  1983. assert "group_concat(column_name order by seq_in_index" in normalized
  1984. assert "tenant_no,source_presign_order_id" in normalized
  1985. assert "wrong index" in normalized
  1986. assert "unique index uk_collection_protocol_presign_source" in normalized
  1987. def test_market_presign_routes_are_fixed_and_expose_scoped_transfer_actions():
  1988. from app.api.market_mobile import router
  1989. actual = {
  1990. (method, route.path)
  1991. for route in router.routes
  1992. if "/presign-orders" in route.path
  1993. for method in route.methods
  1994. }
  1995. expected = set()
  1996. for role in ("specialist", "manager"):
  1997. base = f"/mini/market/{role}/presign-orders"
  1998. expected.update(
  1999. {
  2000. ("GET", base),
  2001. ("POST", base),
  2002. ("GET", f"{base}/form-options"),
  2003. ("GET", f"{base}/{{order_id}}"),
  2004. ("PUT", f"{base}/{{order_id}}"),
  2005. ("DELETE", f"{base}/{{order_id}}"),
  2006. ("POST", f"{base}/{{order_id}}/convert"),
  2007. ("POST", f"{base}/{{order_id}}/transfers"),
  2008. }
  2009. )
  2010. assert actual == expected
  2011. transfer_actual = {
  2012. (method, route.path)
  2013. for route in router.routes
  2014. if "/presign-transfers" in route.path
  2015. for method in route.methods
  2016. }
  2017. transfer_expected = set()
  2018. for role in ("specialist", "manager"):
  2019. base = f"/mini/market/{role}/presign-transfers/{{transfer_id}}"
  2020. transfer_expected.update(
  2021. {
  2022. ("PUT", base),
  2023. ("DELETE", base),
  2024. ("POST", f"{base}/submit"),
  2025. ("POST", f"{base}/withdraw"),
  2026. }
  2027. )
  2028. assert transfer_actual == transfer_expected
  2029. def test_mobile_presign_money_dto_preserves_decimal_strings_for_presenter(
  2030. monkeypatch,
  2031. ):
  2032. import json
  2033. import subprocess
  2034. from decimal import Decimal
  2035. from pathlib import Path
  2036. from app.crud import presign_order as presign_crud
  2037. from app.services import market_presign
  2038. db = _session()
  2039. normal = _order('money-normal', sales_user_id='u1')
  2040. normal.contract_amount = Decimal('1234.50')
  2041. empty = _order('money-empty', sales_user_id='u1')
  2042. empty.contract_amount = None
  2043. paid = _transfer(
  2044. 'money-paid', order_id=normal.id, pay_status='yjf'
  2045. )
  2046. paid.transfer_amount = Decimal('200.25')
  2047. draft = _transfer(
  2048. 'money-draft', order_id=normal.id, pay_status='dfj'
  2049. )
  2050. draft.transfer_amount = Decimal('12.30')
  2051. db.add_all([normal, empty, paid, draft])
  2052. db.commit()
  2053. items, total = market_presign.list_presign_orders(
  2054. db,
  2055. ctx=_ctx('specialist'),
  2056. user_info=_user(),
  2057. page=1,
  2058. page_size=10,
  2059. )
  2060. by_id = {item['id']: item for item in items}
  2061. assert total == 2
  2062. assert by_id['money-normal']['contractAmount'] == '1234.50'
  2063. assert by_id['money-normal']['confirmedDepositAmount'] == '200.25'
  2064. assert by_id['money-empty']['contractAmount'] is None
  2065. assert by_id['money-empty']['confirmedDepositAmount'] == '0.00'
  2066. normal_detail = market_presign.get_presign_order(
  2067. db,
  2068. order_id=normal.id,
  2069. ctx=_ctx('specialist'),
  2070. user_info=_user(),
  2071. )
  2072. transfer_amounts = {
  2073. transfer['id']: transfer['transferAmount']
  2074. for transfer in normal_detail['transfers']
  2075. }
  2076. assert transfer_amounts == {
  2077. 'money-paid': '200.25',
  2078. 'money-draft': '12.30',
  2079. }
  2080. maximum = Decimal('9999999999999999.99')
  2081. maximum_order = _order('money-maximum', sales_user_id='u1')
  2082. maximum_order.contract_amount = maximum
  2083. monkeypatch.setattr(
  2084. market_presign,
  2085. 'assert_presign_access',
  2086. lambda *_args, **_kwargs: maximum_order,
  2087. )
  2088. monkeypatch.setattr(
  2089. presign_crud,
  2090. 'confirmed_deposit_amount',
  2091. lambda *_args, **_kwargs: maximum,
  2092. )
  2093. maximum_detail = market_presign.get_presign_order(
  2094. db,
  2095. order_id=maximum_order.id,
  2096. ctx=_ctx('specialist'),
  2097. user_info=_user(),
  2098. )
  2099. assert maximum_detail['contractAmount'] == str(maximum)
  2100. assert maximum_detail['confirmedDepositAmount'] == str(maximum)
  2101. maximum_transfer = _transfer(
  2102. 'money-maximum-transfer', order_id=maximum_order.id
  2103. )
  2104. maximum_transfer.transfer_amount = maximum
  2105. maximum_transfer_dto = market_presign._mobile_transfer_to_dict(
  2106. db, maximum_transfer, 'tenant-a'
  2107. )
  2108. assert maximum_transfer_dto['transferAmount'] == str(maximum)
  2109. serialized = json.dumps(
  2110. {
  2111. 'maximum': maximum_detail,
  2112. 'maximumTransfer': maximum_transfer_dto,
  2113. 'normal': normal_detail,
  2114. 'empty': by_id['money-empty'],
  2115. },
  2116. ensure_ascii=False,
  2117. )
  2118. presenter_url = (
  2119. Path(__file__).resolve().parents[2]
  2120. / 'mobile-oa-h5/src/market/presign/presign-presenter.js'
  2121. ).as_uri()
  2122. script = """
  2123. import fs from 'node:fs'
  2124. const { formatPresignMoney } = await import(process.argv[1])
  2125. const dto = JSON.parse(fs.readFileSync(0, 'utf8'))
  2126. const values = [
  2127. dto.maximum.contractAmount,
  2128. dto.maximum.confirmedDepositAmount,
  2129. dto.maximumTransfer.transferAmount,
  2130. dto.normal.contractAmount,
  2131. dto.normal.transfers.find((item) => item.id === 'money-paid').transferAmount,
  2132. dto.empty.contractAmount,
  2133. dto.empty.confirmedDepositAmount
  2134. ]
  2135. process.stdout.write(JSON.stringify(values.map(formatPresignMoney)))
  2136. """
  2137. formatted = subprocess.run(
  2138. ['node', '--input-type=module', '-e', script, presenter_url],
  2139. input=serialized,
  2140. text=True,
  2141. capture_output=True,
  2142. check=True,
  2143. )
  2144. assert json.loads(formatted.stdout) == [
  2145. '¥9,999,999,999,999,999.99',
  2146. '¥9,999,999,999,999,999.99',
  2147. '¥9,999,999,999,999,999.99',
  2148. '¥1,234.50',
  2149. '¥200.25',
  2150. '-',
  2151. '¥0.00',
  2152. ]
  2153. def test_mobile_presign_convert_response_preserves_decimal_for_presenter(
  2154. monkeypatch,
  2155. ):
  2156. import json
  2157. import subprocess
  2158. from decimal import Decimal
  2159. from pathlib import Path
  2160. from app.crud import presign_order as presign_crud
  2161. from app.services import market_presign
  2162. db = _session()
  2163. _add_master_data(db)
  2164. db.commit()
  2165. maximum = Decimal('9999999999999999.99')
  2166. order = _order('convert-money', sales_user_id='u1')
  2167. order.protocol_no = 'P-CONVERT-MONEY'
  2168. order.contract_amount = maximum
  2169. monkeypatch.setattr(
  2170. market_presign,
  2171. '_scoped_order_for_update',
  2172. lambda *_args, **_kwargs: order,
  2173. )
  2174. monkeypatch.setattr(
  2175. presign_crud,
  2176. '_get_order_or_raise',
  2177. lambda *_args, **_kwargs: order,
  2178. )
  2179. monkeypatch.setattr(
  2180. presign_crud,
  2181. 'confirmed_deposit_amount',
  2182. lambda *_args, **_kwargs: maximum,
  2183. )
  2184. # SQLite binds Numeric through float. Keep the exact Decimal on the real ORM
  2185. # instance while exercising the complete core conversion and mobile response.
  2186. monkeypatch.setattr(db, 'commit', db.flush)
  2187. monkeypatch.setattr(db, 'refresh', lambda *_args, **_kwargs: None)
  2188. response = market_presign.convert_presign_order(
  2189. db,
  2190. order_id=order.id,
  2191. ctx=_ctx('specialist'),
  2192. user_info=_user(),
  2193. )
  2194. nested = response['presignOrder']
  2195. assert response['protocolId']
  2196. assert order.order_status == 'converted'
  2197. assert nested['contractAmount'] == str(maximum)
  2198. assert nested['confirmedDepositAmount'] == str(maximum)
  2199. presenter_url = (
  2200. Path(__file__).resolve().parents[2]
  2201. / 'mobile-oa-h5/src/market/presign/presign-presenter.js'
  2202. ).as_uri()
  2203. script = """
  2204. import fs from 'node:fs'
  2205. const { formatPresignMoney } = await import(process.argv[1])
  2206. const dto = JSON.parse(fs.readFileSync(0, 'utf8'))
  2207. process.stdout.write(JSON.stringify([
  2208. formatPresignMoney(dto.contractAmount),
  2209. formatPresignMoney(dto.confirmedDepositAmount)
  2210. ]))
  2211. """
  2212. formatted = subprocess.run(
  2213. ['node', '--input-type=module', '-e', script, presenter_url],
  2214. input=json.dumps(nested),
  2215. text=True,
  2216. capture_output=True,
  2217. check=True,
  2218. )
  2219. assert json.loads(formatted.stdout) == [
  2220. '¥9,999,999,999,999,999.99',
  2221. '¥9,999,999,999,999,999.99',
  2222. ]