test_market_sample_api.py 58 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787
  1. import asyncio
  2. from datetime import datetime, timedelta
  3. import pytest
  4. from fastapi import HTTPException
  5. from pydantic import ValidationError
  6. from sqlalchemy import create_engine, event
  7. from sqlalchemy.orm import sessionmaker
  8. from app.models.base import Base
  9. from app.models.models import (
  10. BizAttachment,
  11. BizCellSampleType,
  12. BizCollectionProtocol,
  13. BizCollectionRegionStaff,
  14. BizProtocolCollectionInfo,
  15. BizProtocolFullmoonTodo,
  16. BizProtocolPartyCustomer,
  17. BizProtocolSample,
  18. BizProtocolStorageCert,
  19. BizQcWorkOrder,
  20. BizSampleReception,
  21. BizStorageReleaseReview,
  22. SysPost,
  23. SysPostRole,
  24. SysRole,
  25. SysRoleDataScopeDetail,
  26. SysUser,
  27. SysUserPost,
  28. SysUserRole,
  29. )
  30. from app.models.research_donation import (
  31. BizResearchDonation,
  32. BizResearchDonationCollection,
  33. BizResearchDonationParty,
  34. BizResearchDonationSample,
  35. )
  36. TABLES = [
  37. BizAttachment.__table__,
  38. BizCollectionProtocol.__table__,
  39. BizProtocolCollectionInfo.__table__,
  40. BizProtocolPartyCustomer.__table__,
  41. BizProtocolSample.__table__,
  42. BizResearchDonation.__table__,
  43. BizResearchDonationCollection.__table__,
  44. BizResearchDonationParty.__table__,
  45. BizResearchDonationSample.__table__,
  46. BizCellSampleType.__table__,
  47. BizSampleReception.__table__,
  48. BizQcWorkOrder.__table__,
  49. BizStorageReleaseReview.__table__,
  50. BizProtocolStorageCert.__table__,
  51. BizProtocolFullmoonTodo.__table__,
  52. BizCollectionRegionStaff.__table__,
  53. SysUser.__table__,
  54. SysRole.__table__,
  55. SysRoleDataScopeDetail.__table__,
  56. SysUserRole.__table__,
  57. SysPost.__table__,
  58. SysPostRole.__table__,
  59. SysUserPost.__table__,
  60. ]
  61. def _session():
  62. engine = create_engine('sqlite:///:memory:')
  63. Base.metadata.create_all(engine, tables=TABLES)
  64. db = sessionmaker(bind=engine)()
  65. for index, name in enumerate(
  66. (
  67. 'a.jpg',
  68. 'b.jpg',
  69. 'cert-new.jpg',
  70. 'cert-old.jpg',
  71. 'i.jpg',
  72. 'insurance-new.jpg',
  73. 'insurance-old.jpg',
  74. 'keep.jpg',
  75. 'keep-insurance.jpg',
  76. 'old.jpg',
  77. )
  78. ):
  79. db.add(
  80. BizAttachment(
  81. id=f'att-sample-{index}',
  82. business_type='market:specialist:sample:staging',
  83. original_name=name,
  84. file_ext='jpg',
  85. file_url=name,
  86. file_size=10,
  87. file_type='image/jpeg',
  88. tenant_no='tenant-a',
  89. create_by='u1',
  90. create_dept_id='',
  91. del_flag=0,
  92. )
  93. )
  94. db.commit()
  95. return db
  96. def _ctx(kind='specialist', user_id='u1'):
  97. from app.services.market_field_scope import MarketFieldContext
  98. return MarketFieldContext('tenant-a', user_id, kind)
  99. def _user(user_id='u1'):
  100. return {
  101. 'user_id': user_id,
  102. 'tenant_no': 'tenant-a',
  103. 'role': 'super_admin',
  104. 'region_id': 'dept-a',
  105. }
  106. def _assign_region_staff(db, user_id: str, key: str, region_id: str = 'r1'):
  107. db.add(
  108. BizCollectionRegionStaff(
  109. id=f'staff-{key}',
  110. region_id=region_id,
  111. user_id=user_id,
  112. is_leader=0,
  113. tenant_no='tenant-a',
  114. create_dept_id='',
  115. del_flag=0,
  116. )
  117. )
  118. def test_sample_handle_rejects_external_attachment_reference():
  119. from app.services.market_sample import update_storage_cert
  120. db = _session()
  121. _, _, sample = _cell_sample(db, 'attachment-reject')
  122. with pytest.raises(ValueError, match='附件'):
  123. update_storage_cert(
  124. db,
  125. sample_id=f"cell_storage:{sample.sample_code}",
  126. fields={'attachments': 'https://evil.test/a.jpg'},
  127. ctx=_ctx(),
  128. user_info=_user(),
  129. )
  130. def _cell_sample(
  131. db,
  132. key,
  133. *,
  134. sample_code=None,
  135. sales='u1',
  136. manager='m1',
  137. region='r1',
  138. tenant='tenant-a',
  139. deleted=0,
  140. created=None,
  141. row_id=None,
  142. ):
  143. created = created or datetime(2026, 10, 1, 8, 0)
  144. code = f'C-{key}' if sample_code is None else sample_code
  145. protocol = BizCollectionProtocol(
  146. id=f'cp-{key}',
  147. protocol_no=f'CP-{key}',
  148. protocol_status='sxz',
  149. tenant_no=tenant,
  150. create_dept_id='dept-a',
  151. create_time=created,
  152. del_flag=deleted,
  153. )
  154. collection = BizProtocolCollectionInfo(
  155. id=f'cc-{key}',
  156. collection_protocol_id=protocol.id,
  157. region_id=region,
  158. sales_user_id=sales,
  159. region_manager_user_id=manager,
  160. sample_collect_time=created,
  161. tenant_no=tenant,
  162. create_dept_id='dept-a',
  163. del_flag=deleted,
  164. )
  165. party = BizProtocolPartyCustomer(
  166. id=f'pc-{key}',
  167. collection_protocol_id=protocol.id,
  168. mother_name=f'客户-{key}',
  169. tenant_no=tenant,
  170. create_dept_id='dept-a',
  171. del_flag=deleted,
  172. )
  173. sample = BizProtocolSample(
  174. id=row_id or f'cs-{key}',
  175. collection_protocol_id=protocol.id,
  176. sample_code=code,
  177. sample_status='ycj',
  178. tenant_no=tenant,
  179. create_dept_id='dept-a',
  180. create_time=created,
  181. del_flag=deleted,
  182. )
  183. db.add_all([protocol, collection, party, sample])
  184. db.commit()
  185. return protocol, collection, sample
  186. def _research_sample(
  187. db,
  188. key,
  189. *,
  190. sample_code=None,
  191. sales='u1',
  192. manager='m1',
  193. region='r1',
  194. tenant='tenant-a',
  195. deleted=0,
  196. created=None,
  197. ):
  198. created = created or datetime(2026, 10, 1, 8, 0)
  199. code = f'R-{key}' if sample_code is None else sample_code
  200. donation = BizResearchDonation(
  201. id=f'rd-{key}',
  202. donation_no=f'RD-{key}',
  203. donation_status='sxz',
  204. tenant_no=tenant,
  205. create_dept_id='dept-a',
  206. create_time=created,
  207. del_flag=deleted,
  208. )
  209. collection = BizResearchDonationCollection(
  210. id=f'rc-{key}',
  211. research_donation_id=donation.id,
  212. region_id=region,
  213. sales_user_id=sales,
  214. region_manager_user_id=manager,
  215. sample_collect_time=created,
  216. tenant_no=tenant,
  217. create_dept_id='dept-a',
  218. del_flag=deleted,
  219. )
  220. party = BizResearchDonationParty(
  221. id=f'rp-{key}',
  222. research_donation_id=donation.id,
  223. mother_name=f'科研客户-{key}',
  224. tenant_no=tenant,
  225. create_dept_id='dept-a',
  226. del_flag=deleted,
  227. )
  228. sample = BizResearchDonationSample(
  229. id=f'rs-{key}',
  230. research_donation_id=donation.id,
  231. sample_code=code,
  232. sample_status='jcz',
  233. tenant_no=tenant,
  234. create_dept_id='dept-a',
  235. create_time=created,
  236. del_flag=deleted,
  237. )
  238. db.add_all([donation, collection, party, sample])
  239. db.commit()
  240. return donation, collection, sample
  241. def _downstream(db, key, code, source, *, tenant='tenant-a', when=None, conclusion='tg'):
  242. when = when or datetime(2026, 10, 2, 8, 0)
  243. db.add_all(
  244. [
  245. BizSampleReception(
  246. id=f'rec-{key}',
  247. protocol_sample_id='stale-row-id',
  248. collection_protocol_id='stale-parent-id',
  249. reception_time=when,
  250. accept_conclusion='js',
  251. reception_source=source,
  252. sample_code=code,
  253. tenant_no=tenant,
  254. create_dept_id='dept-a',
  255. create_time=when,
  256. del_flag=0,
  257. ),
  258. BizQcWorkOrder(
  259. id=f'qc-{key}',
  260. work_order_no=f'QC-{key}',
  261. sample_code=code,
  262. reception_source=source,
  263. trigger_source='reception',
  264. work_order_status='ywc',
  265. conclusion=conclusion,
  266. tenant_no=tenant,
  267. create_dept_id='dept-a',
  268. create_time=when,
  269. update_time=when,
  270. del_flag=0,
  271. ),
  272. BizStorageReleaseReview(
  273. id=f'st-{key}',
  274. sample_code=code,
  275. reception_source=source,
  276. storage_status='yrk',
  277. storage_time=when,
  278. tenant_no=tenant,
  279. create_dept_id='dept-a',
  280. create_time=when,
  281. del_flag=0,
  282. ),
  283. ]
  284. )
  285. db.commit()
  286. def test_sample_list_resolves_parent_scope_and_uses_global_stable_paging():
  287. from app.services.market_sample import list_samples
  288. db = _session()
  289. base = datetime(2026, 10, 10, 8, 0)
  290. _cell_sample(db, 'c-old', created=base)
  291. _research_sample(db, 'r-new', created=base + timedelta(hours=3))
  292. _cell_sample(db, 'c-mid', created=base + timedelta(hours=2))
  293. _research_sample(db, 'r-mid', created=base + timedelta(hours=1))
  294. _cell_sample(db, 'other-user', sales='u2', created=base + timedelta(hours=4))
  295. _cell_sample(db, 'cross-tenant', tenant='tenant-b', created=base + timedelta(hours=5))
  296. _research_sample(db, 'deleted', deleted=1, created=base + timedelta(hours=6))
  297. page1, total = list_samples(db, ctx=_ctx(), user_info=_user(), page=1, page_size=2)
  298. page2, total2 = list_samples(db, ctx=_ctx(), user_info=_user(), page=2, page_size=2)
  299. assert total == total2 == 4
  300. assert [item['sampleCode'] for item in page1] == ['R-r-new', 'C-c-mid']
  301. assert [item['sampleCode'] for item in page2] == ['R-r-mid', 'C-c-old']
  302. assert page1[0]['id'] == 'research_donation:R-r-new'
  303. assert page1[1]['id'] == 'cell_storage:C-c-mid'
  304. assert all('statusTone' in item and 'tagType' in item for item in page1)
  305. def test_manager_and_dual_role_use_the_fixed_route_field_scope():
  306. from app.services.market_sample import list_samples
  307. db = _session()
  308. db.add(
  309. BizCollectionRegionStaff(
  310. id='staff-m1',
  311. region_id='r-managed',
  312. user_id='m1',
  313. is_leader=1,
  314. tenant_no='tenant-a',
  315. create_dept_id='',
  316. del_flag=0,
  317. )
  318. )
  319. db.commit()
  320. _cell_sample(db, 'managed', sales='u2', manager='other', region='r-managed')
  321. _cell_sample(db, 'assigned', sales='u2', manager='m1', region='r-other')
  322. _cell_sample(db, 'hidden', sales='m1', manager='other', region='r-other')
  323. manager_items, _ = list_samples(db, ctx=_ctx('manager', 'm1'), user_info=_user('m1'))
  324. specialist_items, _ = list_samples(
  325. db, ctx=_ctx('specialist', 'm1'), user_info=_user('m1')
  326. )
  327. assert {item['sampleCode'] for item in manager_items} == {'C-managed', 'C-assigned'}
  328. assert {item['sampleCode'] for item in specialist_items} == {'C-hidden'}
  329. def test_detail_matches_downstream_only_by_tenant_code_and_source_and_latest():
  330. from app.services.market_sample import get_sample_detail
  331. db = _session()
  332. _cell_sample(db, 'same', sample_code='SAME', row_id='rebuilt-row-id')
  333. _research_sample(db, 'same', sample_code='SAME')
  334. old = datetime(2026, 10, 1, 8, 0)
  335. new = datetime(2026, 10, 2, 8, 0)
  336. _downstream(db, 'cell-old', 'SAME', 'cell_storage', when=old, conclusion='btg')
  337. _downstream(db, 'cell-new', 'SAME', 'cell_storage', when=new, conclusion='tg')
  338. _downstream(db, 'research', 'SAME', 'research_donation', when=new, conclusion='btg')
  339. _downstream(db, 'cross', 'SAME', 'cell_storage', tenant='tenant-b', when=new + timedelta(days=1), conclusion='btg')
  340. cell = get_sample_detail(
  341. db, sample_id='cell_storage:SAME', ctx=_ctx(), user_info=_user()
  342. )
  343. research = get_sample_detail(
  344. db, sample_id='research_donation:SAME', ctx=_ctx(), user_info=_user()
  345. )
  346. assert cell['card']['sampleRowId'] == 'rebuilt-row-id'
  347. assert cell['progress']['qc']['code'] == 'tg'
  348. assert research['progress']['qc']['code'] == 'btg'
  349. assert cell['progress']['reception']['recordId'] == 'rec-cell-new'
  350. assert cell['progress']['storage']['recordId'] == 'st-cell-new'
  351. assert cell['sections']
  352. def test_detail_idor_and_malformed_ids_are_not_exposed():
  353. from app.services.market_sample import get_sample_detail
  354. db = _session()
  355. _cell_sample(db, 'other', sales='u2')
  356. _cell_sample(db, 'cross', tenant='tenant-b')
  357. for sample_id in ('cell_storage:C-other', 'cell_storage:C-cross', 'bad', 'evil:C-other'):
  358. with pytest.raises(HTTPException) as exc:
  359. get_sample_detail(db, sample_id=sample_id, ctx=_ctx(), user_info=_user())
  360. assert exc.value.status_code == 404
  361. def test_empty_sample_code_has_basic_read_only_detail_without_downstream():
  362. from app.services.market_sample import get_sample_detail, list_samples
  363. db = _session()
  364. _cell_sample(db, 'blank', sample_code='')
  365. _downstream(db, 'unrelated', 'C-other', 'cell_storage')
  366. items, total = list_samples(db, ctx=_ctx(), user_info=_user())
  367. assert total == 1
  368. assert items[0]['id'] == 'cell_storage:@id:cs-blank'
  369. detail = get_sample_detail(db, sample_id=items[0]['id'], ctx=_ctx(), user_info=_user())
  370. assert detail['card']['sampleCode'] == ''
  371. assert detail['actions'] == []
  372. assert detail['progress']['reception']['code'] == 'none'
  373. def test_cell_lightweight_storage_cert_and_insurance_writes_validate_handler():
  374. from app.services.market_sample import update_insurance, update_storage_cert
  375. db = _session()
  376. protocol, _, _ = _cell_sample(db, 'write')
  377. db.add_all(
  378. [
  379. SysUser(
  380. id='handler',
  381. user_no='handler',
  382. nick_name='办理人',
  383. email='',
  384. phone_no='',
  385. status='1',
  386. tenant_no='tenant-a',
  387. create_dept_id='',
  388. del_flag=0,
  389. ),
  390. BizCollectionRegionStaff(
  391. id='staff-handler-write',
  392. region_id='r1',
  393. user_id='handler',
  394. is_leader=0,
  395. tenant_no='tenant-a',
  396. create_dept_id='',
  397. del_flag=0,
  398. ),
  399. SysUser(
  400. id='disabled',
  401. user_no='disabled',
  402. nick_name='停用人',
  403. email='',
  404. phone_no='',
  405. status='0',
  406. tenant_no='tenant-a',
  407. create_dept_id='',
  408. del_flag=0,
  409. ),
  410. ]
  411. )
  412. db.commit()
  413. when = '2026-10-11 09:30:00'
  414. cert = update_storage_cert(
  415. db,
  416. sample_id='cell_storage:C-write',
  417. fields={
  418. 'handleTime': when,
  419. 'handlerUserId': 'handler',
  420. 'attachments': 'a.jpg,b.jpg',
  421. 'remark': '证书备注',
  422. },
  423. ctx=_ctx(),
  424. user_info=_user(),
  425. )
  426. insurance = update_insurance(
  427. db,
  428. sample_id='cell_storage:C-write',
  429. fields={
  430. 'handleTime': when,
  431. 'handlerUserId': 'handler',
  432. 'attachments': 'i.jpg',
  433. 'remark': '保险备注',
  434. },
  435. ctx=_ctx(),
  436. user_info=_user(),
  437. )
  438. cert_row = db.query(BizProtocolStorageCert).filter_by(collection_protocol_id=protocol.id).one()
  439. todo_row = db.query(BizProtocolFullmoonTodo).filter_by(collection_protocol_id=protocol.id).one()
  440. assert cert['handlerName'] == insurance['handlerName'] == '办理人'
  441. assert cert_row.cert_image_urls == 'a.jpg,b.jpg'
  442. assert cert_row.remark == '证书备注'
  443. assert todo_row.insurance_attachments == 'i.jpg'
  444. assert todo_row.handle_remark == '保险备注'
  445. with pytest.raises(ValueError, match='办理人员'):
  446. update_storage_cert(
  447. db,
  448. sample_id='cell_storage:C-write',
  449. fields={'handlerUserId': 'disabled'},
  450. ctx=_ctx(),
  451. user_info=_user(),
  452. )
  453. def test_sample_form_options_return_only_enabled_staff_in_scoped_document_region():
  454. from app.services.market_sample import get_sample_form_options
  455. db = _session()
  456. _cell_sample(db, 'handler-options', region='r1')
  457. db.add_all(
  458. [
  459. SysUser(id='eligible', user_no='eligible', nick_name='可办理', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  460. SysUser(id='other-region', user_no='other-region', nick_name='其他地区', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  461. SysUser(id='disabled-staff', user_no='disabled-staff', nick_name='停用人员', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  462. SysUser(id='cross-tenant-staff', user_no='cross-tenant-staff', nick_name='跨租户', email='', phone_no='', status='1', tenant_no='tenant-b', create_dept_id='', del_flag=0),
  463. BizCollectionRegionStaff(id='region-eligible', region_id='r1', user_id='eligible', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
  464. BizCollectionRegionStaff(id='region-other', region_id='r2', user_id='other-region', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
  465. BizCollectionRegionStaff(id='region-disabled', region_id='r1', user_id='disabled-staff', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
  466. BizCollectionRegionStaff(id='region-cross', region_id='r1', user_id='cross-tenant-staff', is_leader=0, tenant_no='tenant-b', create_dept_id='', del_flag=0),
  467. ]
  468. )
  469. db.commit()
  470. result = get_sample_form_options(
  471. db,
  472. sample_id='cell_storage:C-handler-options',
  473. ctx=_ctx(),
  474. user_info=_user(),
  475. )
  476. assert result == {'staff': [{'value': 'eligible', 'label': '可办理'}]}
  477. def test_sample_detail_and_options_resolve_only_referenced_historical_handlers():
  478. from app.services.market_sample import get_sample_detail, get_sample_form_options
  479. db = _session()
  480. protocol, _, sample = _cell_sample(db, 'historical-handlers', region='r1')
  481. db.add_all(
  482. [
  483. SysUser(id='eligible', user_no='eligible-no', nick_name='当前可办理', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  484. SysUser(id='disabled-history', user_no='disabled-no', nick_name='历史停用人员', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  485. SysUser(id='deleted-history', user_no='deleted-no', nick_name='历史删除人员', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=1),
  486. SysUser(id='unrelated-history', user_no='unrelated-no', nick_name='无关历史人员', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1),
  487. SysUser(id='cross-history', user_no='cross-no', nick_name='跨租户历史人员', email='', phone_no='', status='0', tenant_no='tenant-b', create_dept_id='', del_flag=1),
  488. BizCollectionRegionStaff(id='region-eligible-history', region_id='r1', user_id='eligible', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
  489. BizProtocolStorageCert(id='cert-history', collection_protocol_id=protocol.id, handler_user_id='disabled-history', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  490. BizProtocolFullmoonTodo(id='todo-history', collection_protocol_id=protocol.id, handler_user_id='deleted-history', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  491. BizSampleReception(id='reception-history', protocol_sample_id=sample.id, collection_protocol_id=protocol.id, reception_time=datetime(2026, 10, 2, 8, 0), accept_conclusion='js', reception_source='cell_storage', sample_code=sample.sample_code, lab_storage_code='LAB-2026-001', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  492. ]
  493. )
  494. db.commit()
  495. detail = get_sample_detail(
  496. db,
  497. sample_id='cell_storage:C-historical-handlers',
  498. ctx=_ctx(),
  499. user_info=_user(),
  500. )
  501. handles = next(
  502. section for section in detail['sections'] if section['key'] == 'market-handles'
  503. )
  504. basic = next(section for section in detail['sections'] if section['key'] == 'basic')
  505. storage_options = get_sample_form_options(
  506. db,
  507. sample_id='cell_storage:C-historical-handlers',
  508. action='storage-cert',
  509. ctx=_ctx(),
  510. user_info=_user(),
  511. )
  512. insurance_options = get_sample_form_options(
  513. db,
  514. sample_id='cell_storage:C-historical-handlers',
  515. action='insurance',
  516. ctx=_ctx(),
  517. user_info=_user(),
  518. )
  519. default_options = get_sample_form_options(
  520. db,
  521. sample_id='cell_storage:C-historical-handlers',
  522. ctx=_ctx(),
  523. user_info=_user(),
  524. )
  525. assert handles['storageCert']['handlerUserName'] == '历史停用人员'
  526. assert handles['insurance']['handlerUserName'] == '历史删除人员'
  527. assert {'label': '实验室入库编码', 'value': 'LAB-2026-001'} in basic['fields']
  528. assert {item['value']: item['label'] for item in storage_options['staff']} == {
  529. 'eligible': '当前可办理',
  530. 'disabled-history': '历史停用人员',
  531. }
  532. assert {item['value']: item['label'] for item in insurance_options['staff']} == {
  533. 'eligible': '当前可办理',
  534. 'deleted-history': '历史删除人员',
  535. }
  536. assert default_options == {
  537. 'staff': [{'value': 'eligible', 'label': '当前可办理'}]
  538. }
  539. def test_sample_handling_form_options_route_is_not_registered():
  540. from app.api import market_mobile
  541. paths = {route.path for route in market_mobile.router.routes}
  542. assert '/mini/market/specialist/samples/form-options' not in paths
  543. assert '/mini/market/manager/samples/form-options' not in paths
  544. def test_sample_list_batches_referenced_handler_name_lookup():
  545. from app.services.market_sample import list_samples
  546. db = _session()
  547. db.add(
  548. SysUser(id='history-handler', user_no='history-no', nick_name='历史办理人', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1)
  549. )
  550. for index in range(5):
  551. protocol, _, _ = _cell_sample(db, f'handler-batch-{index}')
  552. db.add(
  553. BizProtocolStorageCert(
  554. id=f'cert-handler-batch-{index}',
  555. collection_protocol_id=protocol.id,
  556. handler_user_id='history-handler',
  557. tenant_no='tenant-a',
  558. create_dept_id='',
  559. del_flag=0,
  560. )
  561. )
  562. db.commit()
  563. statements = []
  564. def capture_statement(_conn, _cursor, statement, _parameters, _context, _many):
  565. statements.append(statement)
  566. event.listen(db.get_bind(), 'before_cursor_execute', capture_statement)
  567. try:
  568. items, total = list_samples(db, ctx=_ctx(), user_info=_user(), page_size=10)
  569. finally:
  570. event.remove(db.get_bind(), 'before_cursor_execute', capture_statement)
  571. assert total == len(items) == 5
  572. assert {item['storageCert']['handlerUserName'] for item in items} == {'历史办理人'}
  573. assert sum('FROM sys_user' in statement for statement in statements) == 1
  574. def test_sample_handler_must_be_enabled_staff_in_current_document_region():
  575. from app.services.market_sample import update_storage_cert
  576. db = _session()
  577. _cell_sample(db, 'handler-region', region='r1')
  578. db.add_all(
  579. [
  580. SysUser(id='wrong-region', user_no='wrong-region', nick_name='错区人员', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  581. BizCollectionRegionStaff(id='wrong-region-link', region_id='r2', user_id='wrong-region', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
  582. ]
  583. )
  584. db.commit()
  585. with pytest.raises(ValueError, match='办理人员'):
  586. update_storage_cert(
  587. db,
  588. sample_id='cell_storage:C-handler-region',
  589. fields={'handleTime': '2026-10-11 09:30:00', 'handlerUserId': 'wrong-region'},
  590. ctx=_ctx(),
  591. user_info=_user(),
  592. )
  593. assert db.query(BizProtocolStorageCert).count() == 0
  594. def test_research_and_empty_code_samples_are_read_only_and_scope_is_rechecked():
  595. from app.services.market_sample import update_storage_cert
  596. db = _session()
  597. _research_sample(db, 'readonly')
  598. _, collection, _ = _cell_sample(db, 'moved')
  599. _cell_sample(db, 'blank', sample_code='')
  600. for sample_id in ('research_donation:R-readonly', 'cell_storage:@id:cs-blank'):
  601. with pytest.raises(ValueError, match='不支持'):
  602. update_storage_cert(
  603. db,
  604. sample_id=sample_id,
  605. fields={'remark': 'x'},
  606. ctx=_ctx(),
  607. user_info=_user(),
  608. )
  609. collection.sales_user_id = 'u2'
  610. db.commit()
  611. with pytest.raises(HTTPException) as exc:
  612. update_storage_cert(
  613. db,
  614. sample_id='cell_storage:C-moved',
  615. fields={'remark': 'x'},
  616. ctx=_ctx(),
  617. user_info=_user(),
  618. )
  619. assert exc.value.status_code == 404
  620. def test_lightweight_dtos_forbid_status_qc_production_lab_and_sample_fields():
  621. from app.schemas.market_mobile import (
  622. MarketSampleInsuranceBody,
  623. MarketSampleStorageCertBody,
  624. )
  625. allowed = {
  626. 'handleTime': '2026-10-11 09:30:00',
  627. 'handlerUserId': 'u1',
  628. 'attachments': 'a.jpg',
  629. 'remark': 'ok',
  630. }
  631. MarketSampleStorageCertBody.model_validate(allowed)
  632. MarketSampleInsuranceBody.model_validate(allowed)
  633. for forbidden in (
  634. 'sampleCode',
  635. 'sampleStatus',
  636. 'storageStatus',
  637. 'qcConclusion',
  638. 'productionResult',
  639. 'labStorageCode',
  640. 'makeStatus',
  641. 'todoStatus',
  642. 'tenantNo',
  643. ):
  644. with pytest.raises(ValidationError):
  645. MarketSampleStorageCertBody.model_validate({**allowed, forbidden: 'bad'})
  646. def test_write_rolls_back_when_flush_fails(monkeypatch):
  647. from app.services.market_sample import update_storage_cert
  648. db = _session()
  649. _cell_sample(db, 'rollback')
  650. original_flush = db.flush
  651. def fail_flush(*args, **kwargs):
  652. original_flush(*args, **kwargs)
  653. raise RuntimeError('injected failure')
  654. monkeypatch.setattr(db, 'flush', fail_flush)
  655. with pytest.raises(RuntimeError, match='injected failure'):
  656. update_storage_cert(
  657. db,
  658. sample_id='cell_storage:C-rollback',
  659. fields={'remark': 'must rollback'},
  660. ctx=_ctx(),
  661. user_info=_user(),
  662. )
  663. monkeypatch.setattr(db, 'flush', original_flush)
  664. assert db.query(BizProtocolStorageCert).count() == 0
  665. def test_list_batch_enrichment_has_bounded_query_count():
  666. from app.services.market_sample import list_samples
  667. db = _session()
  668. for index in range(8):
  669. source = 'cell_storage' if index % 2 == 0 else 'research_donation'
  670. if source == 'cell_storage':
  671. _cell_sample(db, f'q{index}')
  672. code = f'C-q{index}'
  673. else:
  674. _research_sample(db, f'q{index}')
  675. code = f'R-q{index}'
  676. _downstream(db, f'q{index}', code, source)
  677. statements = []
  678. def count_statement(*args):
  679. statements.append(args[2])
  680. event.listen(db.bind, 'before_cursor_execute', count_statement)
  681. try:
  682. items, total = list_samples(db, ctx=_ctx(), user_info=_user(), page_size=20)
  683. finally:
  684. event.remove(db.bind, 'before_cursor_execute', count_statement)
  685. assert total == len(items) == 8
  686. assert len(statements) <= 16
  687. def test_market_sample_routes_are_not_exposed_to_market_roles():
  688. from app.api.market_mobile import router
  689. paths = {route.path for route in router.routes}
  690. for kind in ('specialist', 'manager'):
  691. base = f'/mini/market/{kind}/samples'
  692. assert not any(path.startswith(base) for path in paths)
  693. def test_global_identity_query_deduplicates_legacy_duplicate_child_rows():
  694. from app.services.market_sample import list_samples
  695. db = _session()
  696. protocol, _, _ = _cell_sample(db, 'duplicate-child')
  697. db.add(
  698. BizProtocolCollectionInfo(
  699. id='cc-duplicate-child-legacy',
  700. collection_protocol_id=protocol.id,
  701. region_id='r1',
  702. sales_user_id='u1',
  703. tenant_no='tenant-a',
  704. create_dept_id='',
  705. del_flag=0,
  706. )
  707. )
  708. db.commit()
  709. items, total = list_samples(db, ctx=_ctx(), user_info=_user())
  710. assert total == len(items) == 1
  711. def test_partial_handle_update_preserves_omitted_lightweight_fields():
  712. from app.services.market_sample import update_storage_cert
  713. db = _session()
  714. _cell_sample(db, 'partial')
  715. db.add(
  716. SysUser(
  717. id='handler',
  718. user_no='handler',
  719. nick_name='办理人',
  720. email='',
  721. phone_no='',
  722. status='1',
  723. tenant_no='tenant-a',
  724. create_dept_id='',
  725. del_flag=0,
  726. )
  727. )
  728. _assign_region_staff(db, 'handler', 'partial')
  729. db.commit()
  730. update_storage_cert(
  731. db,
  732. sample_id='cell_storage:C-partial',
  733. fields={
  734. 'handleTime': '2026-10-11 09:30:00',
  735. 'handlerUserId': 'handler',
  736. 'attachments': 'old.jpg',
  737. 'remark': '旧备注',
  738. },
  739. ctx=_ctx(),
  740. user_info=_user(),
  741. )
  742. update_storage_cert(
  743. db,
  744. sample_id='cell_storage:C-partial',
  745. fields={'remark': '新备注'},
  746. ctx=_ctx(),
  747. user_info=_user(),
  748. )
  749. row = db.query(BizProtocolStorageCert).one()
  750. assert _fmt(row.handle_time) == '2026-10-11 09:30:00'
  751. assert row.handler_user_id == 'handler'
  752. assert row.cert_image_urls == 'old.jpg'
  753. assert row.remark == '新备注'
  754. def _fmt(value):
  755. return value.strftime('%Y-%m-%d %H:%M:%S') if value else ''
  756. def test_materialized_collection_row_itself_must_be_inside_fixed_scope():
  757. from app.services.market_sample import list_samples
  758. db = _session()
  759. protocol, visible, _ = _cell_sample(db, 'mixed-collection', region='visible-region')
  760. db.add(
  761. BizProtocolCollectionInfo(
  762. id='cc-mixed-hidden',
  763. collection_protocol_id=protocol.id,
  764. region_id='hidden-region',
  765. sales_user_id='u2',
  766. tenant_no='tenant-a',
  767. create_dept_id='',
  768. del_flag=0,
  769. )
  770. )
  771. db.commit()
  772. items, total = list_samples(db, ctx=_ctx(), user_info=_user())
  773. assert total == 1
  774. assert items[0]['regionId'] == visible.region_id
  775. def test_progress_uses_normalized_codes_and_explicit_semantic_tones():
  776. from app.services.market_sample import get_sample_detail
  777. db = _session()
  778. _cell_sample(db, 'tone')
  779. detail = get_sample_detail(
  780. db, sample_id='cell_storage:C-tone', ctx=_ctx(), user_info=_user()
  781. )
  782. assert detail['progress']['collection'] == {
  783. 'code': 'collected',
  784. 'label': '已采集',
  785. 'statusTone': 'success',
  786. 'tagType': 'success',
  787. }
  788. assert detail['progress']['reception']['statusTone'] == 'info'
  789. assert detail['progress']['qc']['statusTone'] == 'warning'
  790. def test_legacy_storage_cert_and_insurance_statuses_are_read_as_handled():
  791. from app.services.market_sample import get_sample_detail
  792. db = _session()
  793. protocol, _, _ = _cell_sample(db, 'legacy-handled')
  794. protocol_alt, _, _ = _cell_sample(db, 'legacy-handled-alt')
  795. db.add_all(
  796. [
  797. BizProtocolStorageCert(
  798. id='cert-legacy',
  799. collection_protocol_id=protocol.id,
  800. make_status='0',
  801. make_date='20261001',
  802. tenant_no='tenant-a',
  803. create_dept_id='',
  804. del_flag=0,
  805. ),
  806. BizProtocolFullmoonTodo(
  807. id='todo-legacy',
  808. collection_protocol_id=protocol.id,
  809. todo_status='1',
  810. tenant_no='tenant-a',
  811. create_dept_id='',
  812. del_flag=0,
  813. ),
  814. BizProtocolStorageCert(
  815. id='cert-legacy-status',
  816. collection_protocol_id=protocol_alt.id,
  817. make_status='1',
  818. tenant_no='tenant-a',
  819. create_dept_id='',
  820. del_flag=0,
  821. ),
  822. BizProtocolFullmoonTodo(
  823. id='todo-legacy-date',
  824. collection_protocol_id=protocol_alt.id,
  825. todo_status='0',
  826. handle_date='20261002',
  827. tenant_no='tenant-a',
  828. create_dept_id='',
  829. del_flag=0,
  830. ),
  831. ]
  832. )
  833. db.commit()
  834. detail = get_sample_detail(
  835. db,
  836. sample_id='cell_storage:C-legacy-handled',
  837. ctx=_ctx(),
  838. user_info=_user(),
  839. )
  840. section = next(item for item in detail['sections'] if item['key'] == 'market-handles')
  841. assert section['storageCert']['code'] == 'handled'
  842. assert section['storageCert']['statusTone'] == 'success'
  843. assert section['insurance']['code'] == 'handled'
  844. assert section['insurance']['statusTone'] == 'success'
  845. alt = get_sample_detail(
  846. db,
  847. sample_id='cell_storage:C-legacy-handled-alt',
  848. ctx=_ctx(),
  849. user_info=_user(),
  850. )
  851. alt_section = next(
  852. item for item in alt['sections'] if item['key'] == 'market-handles'
  853. )
  854. assert alt_section['storageCert']['code'] == 'handled'
  855. assert alt_section['insurance']['code'] == 'handled'
  856. def test_setting_handle_time_synchronizes_legacy_status_and_date_for_both_actions():
  857. from app.services.market_sample import update_insurance, update_storage_cert
  858. db = _session()
  859. _cell_sample(db, 'sync-set')
  860. db.add(
  861. SysUser(
  862. id='handler',
  863. user_no='handler',
  864. nick_name='办理人',
  865. email='',
  866. phone_no='',
  867. status='1',
  868. tenant_no='tenant-a',
  869. create_dept_id='',
  870. del_flag=0,
  871. )
  872. )
  873. _assign_region_staff(db, 'handler', 'sync-set')
  874. db.commit()
  875. fields = {
  876. 'handleTime': '2026-10-11 09:30:00',
  877. 'handlerUserId': 'handler',
  878. 'attachments': 'keep.jpg',
  879. 'remark': '保留备注',
  880. }
  881. update_storage_cert(
  882. db,
  883. sample_id='cell_storage:C-sync-set',
  884. fields=fields,
  885. ctx=_ctx(),
  886. user_info=_user(),
  887. )
  888. update_insurance(
  889. db,
  890. sample_id='cell_storage:C-sync-set',
  891. fields={**fields, 'attachments': 'keep-insurance.jpg'},
  892. ctx=_ctx(),
  893. user_info=_user(),
  894. )
  895. cert = db.query(BizProtocolStorageCert).one()
  896. todo = db.query(BizProtocolFullmoonTodo).one()
  897. assert (cert.make_status, cert.make_date) == ('1', '20261011')
  898. assert (todo.todo_status, todo.handle_date) == ('1', '20261011')
  899. def test_clearing_handle_time_clears_handler_and_legacy_status_but_preserves_content():
  900. from app.services.market_sample import update_insurance, update_storage_cert
  901. db = _session()
  902. _cell_sample(db, 'sync-clear')
  903. db.add(
  904. SysUser(
  905. id='handler',
  906. user_no='handler',
  907. nick_name='办理人',
  908. email='',
  909. phone_no='',
  910. status='1',
  911. tenant_no='tenant-a',
  912. create_dept_id='',
  913. del_flag=0,
  914. )
  915. )
  916. _assign_region_staff(db, 'handler', 'sync-clear')
  917. db.commit()
  918. initial = {
  919. 'handleTime': '2026-10-11 09:30:00',
  920. 'handlerUserId': 'handler',
  921. 'attachments': 'keep.jpg',
  922. 'remark': '保留备注',
  923. }
  924. for updater, attachment in (
  925. (update_storage_cert, 'keep.jpg'),
  926. (update_insurance, 'keep-insurance.jpg'),
  927. ):
  928. updater(
  929. db,
  930. sample_id='cell_storage:C-sync-clear',
  931. fields={**initial, 'attachments': attachment},
  932. ctx=_ctx(),
  933. user_info=_user(),
  934. )
  935. updater(
  936. db,
  937. sample_id='cell_storage:C-sync-clear',
  938. fields={'handleTime': None, 'handlerUserId': 'handler'},
  939. ctx=_ctx(),
  940. user_info=_user(),
  941. )
  942. cert = db.query(BizProtocolStorageCert).one()
  943. todo = db.query(BizProtocolFullmoonTodo).one()
  944. assert (cert.handle_time, cert.handler_user_id, cert.make_status, cert.make_date) == (
  945. None,
  946. None,
  947. '0',
  948. None,
  949. )
  950. assert (todo.handle_time, todo.handler_user_id, todo.todo_status, todo.handle_date) == (
  951. None,
  952. None,
  953. '0',
  954. None,
  955. )
  956. assert (cert.cert_image_urls, cert.remark) == ('keep.jpg', '保留备注')
  957. assert (todo.insurance_attachments, todo.handle_remark) == (
  958. 'keep-insurance.jpg',
  959. '保留备注',
  960. )
  961. def test_partial_insurance_update_preserves_omitted_lightweight_fields():
  962. from app.services.market_sample import update_insurance
  963. db = _session()
  964. _cell_sample(db, 'partial-insurance')
  965. db.add(
  966. SysUser(
  967. id='handler',
  968. user_no='handler',
  969. nick_name='办理人',
  970. email='',
  971. phone_no='',
  972. status='1',
  973. tenant_no='tenant-a',
  974. create_dept_id='',
  975. del_flag=0,
  976. )
  977. )
  978. _assign_region_staff(db, 'handler', 'partial-insurance')
  979. db.commit()
  980. update_insurance(
  981. db,
  982. sample_id='cell_storage:C-partial-insurance',
  983. fields={
  984. 'handleTime': '2026-10-11 09:30:00',
  985. 'handlerUserId': 'handler',
  986. 'attachments': 'old.jpg',
  987. 'remark': '旧备注',
  988. },
  989. ctx=_ctx(),
  990. user_info=_user(),
  991. )
  992. update_insurance(
  993. db,
  994. sample_id='cell_storage:C-partial-insurance',
  995. fields={'remark': '新备注'},
  996. ctx=_ctx(),
  997. user_info=_user(),
  998. )
  999. row = db.query(BizProtocolFullmoonTodo).one()
  1000. assert _fmt(row.handle_time) == '2026-10-11 09:30:00'
  1001. assert row.handler_user_id == 'handler'
  1002. assert row.insurance_attachments == 'old.jpg'
  1003. assert row.handle_remark == '新备注'
  1004. def test_partial_update_preserves_legacy_handled_fields_when_time_is_omitted():
  1005. from app.services.market_sample import update_insurance, update_storage_cert
  1006. db = _session()
  1007. protocol, _, _ = _cell_sample(db, 'partial-legacy')
  1008. db.add_all(
  1009. [
  1010. SysUser(
  1011. id='handler',
  1012. user_no='handler',
  1013. nick_name='办理人',
  1014. email='',
  1015. phone_no='',
  1016. status='1',
  1017. tenant_no='tenant-a',
  1018. create_dept_id='',
  1019. del_flag=0,
  1020. ),
  1021. BizProtocolStorageCert(
  1022. id='cert-partial-legacy',
  1023. collection_protocol_id=protocol.id,
  1024. make_status='1',
  1025. make_date='20261001',
  1026. handler_user_id='handler',
  1027. cert_image_urls='cert-old.jpg',
  1028. tenant_no='tenant-a',
  1029. create_dept_id='',
  1030. del_flag=0,
  1031. ),
  1032. BizProtocolFullmoonTodo(
  1033. id='todo-partial-legacy',
  1034. collection_protocol_id=protocol.id,
  1035. todo_status='1',
  1036. handle_date='20261002',
  1037. handler_user_id='handler',
  1038. insurance_attachments='insurance-old.jpg',
  1039. tenant_no='tenant-a',
  1040. create_dept_id='',
  1041. del_flag=0,
  1042. ),
  1043. ]
  1044. )
  1045. db.commit()
  1046. update_storage_cert(
  1047. db,
  1048. sample_id='cell_storage:C-partial-legacy',
  1049. fields={'remark': '证书新备注'},
  1050. ctx=_ctx(),
  1051. user_info=_user(),
  1052. )
  1053. update_insurance(
  1054. db,
  1055. sample_id='cell_storage:C-partial-legacy',
  1056. fields={'remark': '保险新备注'},
  1057. ctx=_ctx(),
  1058. user_info=_user(),
  1059. )
  1060. cert = db.query(BizProtocolStorageCert).one()
  1061. todo = db.query(BizProtocolFullmoonTodo).one()
  1062. assert (cert.make_status, cert.make_date, cert.handler_user_id) == (
  1063. '1',
  1064. '20261001',
  1065. 'handler',
  1066. )
  1067. assert cert.cert_image_urls == 'cert-old.jpg'
  1068. assert (todo.todo_status, todo.handle_date, todo.handler_user_id) == (
  1069. '1',
  1070. '20261002',
  1071. 'handler',
  1072. )
  1073. assert todo.insurance_attachments == 'insurance-old.jpg'
  1074. def test_handler_only_update_changes_legacy_handled_records_without_clearing_status():
  1075. from app.services.market_sample import update_insurance, update_storage_cert
  1076. db = _session()
  1077. protocol, _, _ = _cell_sample(db, 'handler-only-legacy')
  1078. db.add_all(
  1079. [
  1080. SysUser(
  1081. id='new-handler',
  1082. user_no='new-handler',
  1083. nick_name='新办理人',
  1084. email='',
  1085. phone_no='',
  1086. status='1',
  1087. tenant_no='tenant-a',
  1088. create_dept_id='',
  1089. del_flag=0,
  1090. ),
  1091. BizProtocolStorageCert(
  1092. id='cert-handler-only',
  1093. collection_protocol_id=protocol.id,
  1094. make_status='1',
  1095. make_date='20261001',
  1096. tenant_no='tenant-a',
  1097. create_dept_id='',
  1098. del_flag=0,
  1099. ),
  1100. BizProtocolFullmoonTodo(
  1101. id='todo-handler-only',
  1102. collection_protocol_id=protocol.id,
  1103. todo_status='0',
  1104. handle_date='20261002',
  1105. tenant_no='tenant-a',
  1106. create_dept_id='',
  1107. del_flag=0,
  1108. ),
  1109. ]
  1110. )
  1111. _assign_region_staff(db, 'new-handler', 'handler-only-legacy')
  1112. db.commit()
  1113. for updater in (update_storage_cert, update_insurance):
  1114. result = updater(
  1115. db,
  1116. sample_id='cell_storage:C-handler-only-legacy',
  1117. fields={'handlerUserId': 'new-handler'},
  1118. ctx=_ctx(),
  1119. user_info=_user(),
  1120. )
  1121. assert result['handlerName'] == '新办理人'
  1122. cert = db.query(BizProtocolStorageCert).one()
  1123. todo = db.query(BizProtocolFullmoonTodo).one()
  1124. assert (cert.handler_user_id, cert.make_status, cert.make_date) == (
  1125. 'new-handler',
  1126. '1',
  1127. '20261001',
  1128. )
  1129. assert (todo.handler_user_id, todo.todo_status, todo.handle_date) == (
  1130. 'new-handler',
  1131. '0',
  1132. '20261002',
  1133. )
  1134. def test_handler_only_update_rejects_truly_unhandled_cert_and_insurance():
  1135. from app.services.market_sample import update_insurance, update_storage_cert
  1136. db = _session()
  1137. protocol, _, _ = _cell_sample(db, 'handler-only-unhandled')
  1138. db.add_all(
  1139. [
  1140. SysUser(
  1141. id='handler',
  1142. user_no='handler',
  1143. nick_name='办理人',
  1144. email='',
  1145. phone_no='',
  1146. status='1',
  1147. tenant_no='tenant-a',
  1148. create_dept_id='',
  1149. del_flag=0,
  1150. ),
  1151. BizProtocolStorageCert(
  1152. id='cert-unhandled',
  1153. collection_protocol_id=protocol.id,
  1154. make_status='0',
  1155. tenant_no='tenant-a',
  1156. create_dept_id='',
  1157. del_flag=0,
  1158. ),
  1159. BizProtocolFullmoonTodo(
  1160. id='todo-unhandled',
  1161. collection_protocol_id=protocol.id,
  1162. todo_status='0',
  1163. tenant_no='tenant-a',
  1164. create_dept_id='',
  1165. del_flag=0,
  1166. ),
  1167. ]
  1168. )
  1169. db.commit()
  1170. for updater in (update_storage_cert, update_insurance):
  1171. with pytest.raises(ValueError, match='未办理'):
  1172. updater(
  1173. db,
  1174. sample_id='cell_storage:C-handler-only-unhandled',
  1175. fields={'handlerUserId': 'handler'},
  1176. ctx=_ctx(),
  1177. user_info=_user(),
  1178. )
  1179. def test_historical_disabled_handler_does_not_block_content_only_updates():
  1180. from app.services.market_sample import update_insurance, update_storage_cert
  1181. db = _session()
  1182. protocol, _, _ = _cell_sample(db, 'disabled-history')
  1183. db.add_all(
  1184. [
  1185. SysUser(
  1186. id='disabled-history-handler',
  1187. user_no='disabled-history-handler',
  1188. nick_name='历史办理人',
  1189. email='',
  1190. phone_no='',
  1191. status='0',
  1192. tenant_no='tenant-a',
  1193. create_dept_id='',
  1194. del_flag=1,
  1195. ),
  1196. BizProtocolStorageCert(
  1197. id='cert-disabled-history',
  1198. collection_protocol_id=protocol.id,
  1199. handle_time=datetime(2026, 10, 1, 8, 0),
  1200. handler_user_id='disabled-history-handler',
  1201. make_status='1',
  1202. make_date='20261001',
  1203. tenant_no='tenant-a',
  1204. create_dept_id='',
  1205. del_flag=0,
  1206. ),
  1207. BizProtocolFullmoonTodo(
  1208. id='todo-disabled-history',
  1209. collection_protocol_id=protocol.id,
  1210. handle_time=datetime(2026, 10, 2, 8, 0),
  1211. handler_user_id='disabled-history-handler',
  1212. todo_status='1',
  1213. handle_date='20261002',
  1214. tenant_no='tenant-a',
  1215. create_dept_id='',
  1216. del_flag=0,
  1217. ),
  1218. ]
  1219. )
  1220. db.commit()
  1221. update_storage_cert(
  1222. db,
  1223. sample_id='cell_storage:C-disabled-history',
  1224. fields={'attachments': 'cert-new.jpg', 'remark': '证书新备注'},
  1225. ctx=_ctx(),
  1226. user_info=_user(),
  1227. )
  1228. update_insurance(
  1229. db,
  1230. sample_id='cell_storage:C-disabled-history',
  1231. fields={'attachments': 'insurance-new.jpg', 'remark': '保险新备注'},
  1232. ctx=_ctx(),
  1233. user_info=_user(),
  1234. )
  1235. cert = db.query(BizProtocolStorageCert).one()
  1236. todo = db.query(BizProtocolFullmoonTodo).one()
  1237. assert cert.handler_user_id == todo.handler_user_id == 'disabled-history-handler'
  1238. assert (cert.cert_image_urls, cert.remark) == ('cert-new.jpg', '证书新备注')
  1239. assert (todo.insurance_attachments, todo.handle_remark) == (
  1240. 'insurance-new.jpg',
  1241. '保险新备注',
  1242. )
  1243. @pytest.mark.parametrize('handler_state', ['disabled', 'deleted', 'out-of-region'])
  1244. @pytest.mark.parametrize('kind', ['storage-cert', 'insurance'])
  1245. def test_full_unchanged_historical_handler_payload_allows_content_update(
  1246. handler_state, kind
  1247. ):
  1248. from app.services.market_sample import update_insurance, update_storage_cert
  1249. db = _session()
  1250. protocol, _, _ = _cell_sample(db, f'historical-full-{handler_state}-{kind}')
  1251. handler_id = f'historical-{handler_state}-{kind}'
  1252. handle_time = datetime(2026, 10, 1, 8, 0)
  1253. user = SysUser(
  1254. id=handler_id,
  1255. user_no=handler_id,
  1256. nick_name='历史办理人',
  1257. email='',
  1258. phone_no='',
  1259. status='0' if handler_state == 'disabled' else '1',
  1260. tenant_no='tenant-a',
  1261. create_dept_id='',
  1262. del_flag=1 if handler_state == 'deleted' else 0,
  1263. )
  1264. staff = BizCollectionRegionStaff(
  1265. id=f'staff-{handler_id}',
  1266. region_id='r2' if handler_state == 'out-of-region' else 'r1',
  1267. user_id=handler_id,
  1268. is_leader=0,
  1269. tenant_no='tenant-a',
  1270. create_dept_id='',
  1271. del_flag=0,
  1272. )
  1273. db.add_all([user, staff])
  1274. if kind == 'storage-cert':
  1275. db.add(
  1276. BizProtocolStorageCert(
  1277. id=f'cert-{handler_id}',
  1278. collection_protocol_id=protocol.id,
  1279. handle_time=handle_time,
  1280. handler_user_id=handler_id,
  1281. cert_image_urls='cert-old.jpg',
  1282. make_status='1',
  1283. tenant_no='tenant-a',
  1284. create_dept_id='',
  1285. del_flag=0,
  1286. )
  1287. )
  1288. updater = update_storage_cert
  1289. attachments = 'cert-new.jpg'
  1290. else:
  1291. db.add(
  1292. BizProtocolFullmoonTodo(
  1293. id=f'todo-{handler_id}',
  1294. collection_protocol_id=protocol.id,
  1295. handle_time=handle_time,
  1296. handler_user_id=handler_id,
  1297. insurance_attachments='insurance-old.jpg',
  1298. todo_status='1',
  1299. tenant_no='tenant-a',
  1300. create_dept_id='',
  1301. del_flag=0,
  1302. )
  1303. )
  1304. updater = update_insurance
  1305. attachments = 'insurance-new.jpg'
  1306. db.commit()
  1307. result = updater(
  1308. db,
  1309. sample_id=f'cell_storage:C-historical-full-{handler_state}-{kind}',
  1310. fields={
  1311. 'handleTime': '2026-10-01 08:00:00',
  1312. 'handlerUserId': handler_id,
  1313. 'attachments': attachments,
  1314. 'remark': '只改内容',
  1315. },
  1316. ctx=_ctx(),
  1317. user_info=_user(),
  1318. )
  1319. assert result['handlerUserId'] == handler_id
  1320. assert result['handleTime'] == '2026-10-01 08:00:00'
  1321. assert result['attachments'] == attachments
  1322. assert result['remark'] == '只改内容'
  1323. @pytest.mark.parametrize('kind', ['storage-cert', 'insurance'])
  1324. def test_changed_handle_time_revalidates_historical_ineligible_handler(kind):
  1325. from app.services.market_sample import update_insurance, update_storage_cert
  1326. db = _session()
  1327. protocol, _, _ = _cell_sample(db, f'historical-time-change-{kind}')
  1328. handler_id = f'disabled-time-change-{kind}'
  1329. db.add(
  1330. SysUser(
  1331. id=handler_id,
  1332. user_no=handler_id,
  1333. nick_name='停用历史办理人',
  1334. email='',
  1335. phone_no='',
  1336. status='0',
  1337. tenant_no='tenant-a',
  1338. create_dept_id='',
  1339. del_flag=0,
  1340. )
  1341. )
  1342. if kind == 'storage-cert':
  1343. row = BizProtocolStorageCert(
  1344. id=f'cert-{handler_id}',
  1345. collection_protocol_id=protocol.id,
  1346. handle_time=datetime(2026, 10, 1, 8, 0),
  1347. handler_user_id=handler_id,
  1348. make_status='1',
  1349. tenant_no='tenant-a',
  1350. create_dept_id='',
  1351. del_flag=0,
  1352. )
  1353. updater = update_storage_cert
  1354. else:
  1355. row = BizProtocolFullmoonTodo(
  1356. id=f'todo-{handler_id}',
  1357. collection_protocol_id=protocol.id,
  1358. handle_time=datetime(2026, 10, 1, 8, 0),
  1359. handler_user_id=handler_id,
  1360. todo_status='1',
  1361. tenant_no='tenant-a',
  1362. create_dept_id='',
  1363. del_flag=0,
  1364. )
  1365. updater = update_insurance
  1366. db.add(row)
  1367. db.commit()
  1368. with pytest.raises(ValueError, match='办理人员'):
  1369. updater(
  1370. db,
  1371. sample_id=f'cell_storage:C-historical-time-change-{kind}',
  1372. fields={
  1373. 'handleTime': '2026-10-02 08:00:00',
  1374. 'handlerUserId': handler_id,
  1375. 'remark': '不得写入',
  1376. },
  1377. ctx=_ctx(),
  1378. user_info=_user(),
  1379. )
  1380. db.expire_all()
  1381. assert row.handle_time == datetime(2026, 10, 1, 8, 0)
  1382. def test_first_handle_parent_lock_compiles_to_mysql_for_update():
  1383. from sqlalchemy.dialects import mysql
  1384. from app.services.market_sample import _cell_parent_lock_query
  1385. db = _session()
  1386. query = _cell_parent_lock_query(db, 'cp-lock', _ctx())
  1387. sql = str(
  1388. query.statement.compile(
  1389. dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}
  1390. )
  1391. )
  1392. assert 'FOR UPDATE' in sql
  1393. assert "biz_collection_protocol.id = 'cp-lock'" in sql
  1394. assert "biz_collection_protocol.tenant_no = 'tenant-a'" in sql
  1395. assert 'biz_collection_protocol.del_flag = 0' in sql
  1396. def test_repeated_first_handle_requests_reuse_single_child_row():
  1397. from app.services.market_sample import update_insurance, update_storage_cert
  1398. db = _session()
  1399. _cell_sample(db, 'single-child')
  1400. for updater in (update_storage_cert, update_insurance):
  1401. updater(
  1402. db,
  1403. sample_id='cell_storage:C-single-child',
  1404. fields={'remark': 'first'},
  1405. ctx=_ctx(),
  1406. user_info=_user(),
  1407. )
  1408. updater(
  1409. db,
  1410. sample_id='cell_storage:C-single-child',
  1411. fields={'remark': 'second'},
  1412. ctx=_ctx(),
  1413. user_info=_user(),
  1414. )
  1415. assert db.query(BizProtocolStorageCert).count() == 1
  1416. assert db.query(BizProtocolFullmoonTodo).count() == 1
  1417. assert db.query(BizProtocolStorageCert).one().remark == 'second'
  1418. assert db.query(BizProtocolFullmoonTodo).one().handle_remark == 'second'
  1419. def test_voided_progress_uses_danger_tone():
  1420. from app.services.market_sample import get_sample_detail
  1421. db = _session()
  1422. _, _, sample = _cell_sample(db, 'voided-tone')
  1423. sample.sample_status = 'yzf'
  1424. db.commit()
  1425. detail = get_sample_detail(
  1426. db,
  1427. sample_id='cell_storage:C-voided-tone',
  1428. ctx=_ctx(),
  1429. user_info=_user(),
  1430. )
  1431. assert detail['progress']['collection']['code'] == 'voided'
  1432. assert detail['progress']['collection']['statusTone'] == 'danger'
  1433. assert detail['progress']['collection']['tagType'] == 'danger'
  1434. def test_source_qualified_whitespace_token_returns_uniform_404():
  1435. from app.services.market_sample import get_sample_detail
  1436. db = _session()
  1437. for sample_id in ('cell_storage: ', 'research_donation:\t'):
  1438. with pytest.raises(HTTPException) as exc:
  1439. get_sample_detail(
  1440. db, sample_id=sample_id, ctx=_ctx(), user_info=_user()
  1441. )
  1442. assert exc.value.status_code == 404
  1443. assert exc.value.detail == '数据不存在或无权访问'
  1444. def test_duplicate_active_children_use_same_newest_fields_in_list_and_detail():
  1445. from app.services.market_sample import get_sample_detail, list_samples
  1446. db = _session()
  1447. protocol, collection, _ = _cell_sample(db, 'deterministic')
  1448. collection.create_time = datetime(2026, 10, 1, 8, 0)
  1449. original_party = db.query(BizProtocolPartyCustomer).filter_by(
  1450. collection_protocol_id=protocol.id
  1451. ).one()
  1452. original_party.create_time = datetime(2026, 10, 1, 8, 0)
  1453. db.add_all(
  1454. [
  1455. BizProtocolCollectionInfo(
  1456. id='cc-deterministic-new',
  1457. collection_protocol_id=protocol.id,
  1458. region_id='newest-region',
  1459. sales_user_id='u1',
  1460. tenant_no='tenant-a',
  1461. create_dept_id='',
  1462. create_time=datetime(2026, 10, 2, 8, 0),
  1463. del_flag=0,
  1464. ),
  1465. BizProtocolPartyCustomer(
  1466. id='pc-deterministic-new',
  1467. collection_protocol_id=protocol.id,
  1468. mother_name='最新客户',
  1469. tenant_no='tenant-a',
  1470. create_dept_id='',
  1471. create_time=datetime(2026, 10, 2, 8, 0),
  1472. del_flag=0,
  1473. ),
  1474. ]
  1475. )
  1476. db.commit()
  1477. items, total = list_samples(db, ctx=_ctx(), user_info=_user())
  1478. detail = get_sample_detail(
  1479. db,
  1480. sample_id='cell_storage:C-deterministic',
  1481. ctx=_ctx(),
  1482. user_info=_user(),
  1483. )
  1484. assert total == 1
  1485. assert items[0]['regionId'] == detail['card']['regionId'] == 'newest-region'
  1486. assert items[0]['customerName'] == detail['card']['customerName'] == '最新客户'
  1487. def test_write_reasserts_current_scope_after_parent_lock(monkeypatch):
  1488. from app.services import market_sample
  1489. db = _session()
  1490. _, collection, _ = _cell_sample(db, 'scope-race')
  1491. original_find = market_sample._find_sample
  1492. calls = []
  1493. def move_out_of_scope_after_initial_read(*args, **kwargs):
  1494. calls.append(bool(kwargs.get('for_update')))
  1495. item = original_find(*args, **kwargs)
  1496. if len(calls) == 1:
  1497. collection.sales_user_id = 'u2'
  1498. db.commit()
  1499. return item
  1500. monkeypatch.setattr(
  1501. market_sample, '_find_sample', move_out_of_scope_after_initial_read
  1502. )
  1503. with pytest.raises(HTTPException) as exc:
  1504. market_sample.update_storage_cert(
  1505. db,
  1506. sample_id='cell_storage:C-scope-race',
  1507. fields={'remark': 'must not write'},
  1508. ctx=_ctx(),
  1509. user_info=_user(),
  1510. )
  1511. assert exc.value.status_code == 404
  1512. assert calls == [False, True]
  1513. assert not db.in_transaction()
  1514. assert db.query(BizProtocolStorageCert).count() == 0
  1515. def test_current_sample_and_child_reads_compile_as_mysql_locking_reads():
  1516. from sqlalchemy.dialects import mysql
  1517. from app.services.market_sample import (
  1518. _cell_sample_lookup_query,
  1519. _handle_child_current_query,
  1520. )
  1521. db = _session()
  1522. sample_query = _cell_sample_lookup_query(
  1523. db,
  1524. token='C-locking',
  1525. by_row_id=False,
  1526. ctx=_ctx(),
  1527. user_info=_user(),
  1528. for_update=True,
  1529. )
  1530. cert_query = _handle_child_current_query(
  1531. db, BizProtocolStorageCert, 'cp-locking', _ctx()
  1532. )
  1533. todo_query = _handle_child_current_query(
  1534. db, BizProtocolFullmoonTodo, 'cp-locking', _ctx()
  1535. )
  1536. def mysql_sql(query):
  1537. return str(
  1538. query.statement.compile(
  1539. dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}
  1540. )
  1541. )
  1542. sample_sql = mysql_sql(sample_query)
  1543. cert_sql = mysql_sql(cert_query)
  1544. todo_sql = mysql_sql(todo_query)
  1545. assert 'FOR UPDATE' in sample_sql
  1546. assert 'FOR UPDATE' in cert_sql
  1547. assert 'FOR UPDATE' in todo_sql
  1548. assert 'ORDER BY biz_protocol_storage_cert.update_time DESC' in cert_sql
  1549. assert 'ORDER BY biz_protocol_fullmoon_todo.update_time DESC' in todo_sql
  1550. def test_write_lock_order_is_parent_then_current_sample_then_child(monkeypatch):
  1551. from app.services import market_sample
  1552. db = _session()
  1553. _cell_sample(db, 'lock-order')
  1554. events = []
  1555. original_parent_lock = market_sample._lock_cell_parent
  1556. original_find = market_sample._find_sample
  1557. original_child_query = market_sample._handle_child_current_query
  1558. def record_parent(*args, **kwargs):
  1559. events.append('parent')
  1560. return original_parent_lock(*args, **kwargs)
  1561. def record_find(*args, **kwargs):
  1562. events.append('sample-current' if kwargs.get('for_update') else 'sample-initial')
  1563. return original_find(*args, **kwargs)
  1564. def record_child(*args, **kwargs):
  1565. events.append('child')
  1566. return original_child_query(*args, **kwargs)
  1567. monkeypatch.setattr(market_sample, '_lock_cell_parent', record_parent)
  1568. monkeypatch.setattr(market_sample, '_find_sample', record_find)
  1569. monkeypatch.setattr(
  1570. market_sample, '_handle_child_current_query', record_child
  1571. )
  1572. market_sample.update_storage_cert(
  1573. db,
  1574. sample_id='cell_storage:C-lock-order',
  1575. fields={'remark': 'locked'},
  1576. ctx=_ctx(),
  1577. user_info=_user(),
  1578. )
  1579. assert events == ['sample-initial', 'parent', 'sample-current', 'child']