test_market_sample_api.py 53 KB

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