test_market_protocol_api.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937
  1. from datetime import datetime, timedelta
  2. from unittest.mock import patch
  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. BizCollectionHospital,
  13. BizCollectionProtocol,
  14. BizCollectionRegion,
  15. BizCollectionRegionStaff,
  16. BizCustomer,
  17. BizItemUnit,
  18. BizProtocolCollectionInfo,
  19. BizProtocolPartyCustomer,
  20. BizProtocolPayment,
  21. BizProtocolSample,
  22. BizSampleCode,
  23. SysPost,
  24. SysPostRole,
  25. SysRole,
  26. SysRoleDataScopeDetail,
  27. SysUser,
  28. SysUserPost,
  29. SysUserRole,
  30. )
  31. from app.models.research_donation import (
  32. BizProtocolDonationConvert,
  33. BizResearchDonation,
  34. BizResearchDonationCollection,
  35. BizResearchDonationParty,
  36. BizResearchDonationSample,
  37. BizResearchDonationSampleTransport,
  38. )
  39. def _session():
  40. engine = create_engine('sqlite:///:memory:')
  41. Base.metadata.create_all(
  42. engine,
  43. tables=[
  44. BizCollectionProtocol.__table__,
  45. BizProtocolPartyCustomer.__table__,
  46. BizProtocolCollectionInfo.__table__,
  47. BizProtocolSample.__table__,
  48. BizProtocolPayment.__table__,
  49. BizSampleCode.__table__,
  50. BizResearchDonation.__table__,
  51. BizResearchDonationParty.__table__,
  52. BizResearchDonationCollection.__table__,
  53. BizResearchDonationSample.__table__,
  54. BizResearchDonationSampleTransport.__table__,
  55. BizProtocolDonationConvert.__table__,
  56. BizCollectionRegionStaff.__table__,
  57. BizCollectionRegion.__table__,
  58. BizCollectionHospital.__table__,
  59. BizCellSampleType.__table__,
  60. BizItemUnit.__table__,
  61. BizCustomer.__table__,
  62. BizAttachment.__table__,
  63. SysUser.__table__,
  64. SysRole.__table__,
  65. SysRoleDataScopeDetail.__table__,
  66. SysUserRole.__table__,
  67. SysPost.__table__,
  68. SysPostRole.__table__,
  69. SysUserPost.__table__,
  70. ],
  71. )
  72. return sessionmaker(bind=engine)()
  73. def _ctx(scope_kind, user_id='u1'):
  74. from app.services.market_field_scope import MarketFieldContext
  75. return MarketFieldContext('tenant-a', user_id, scope_kind)
  76. def _user(user_id='u1'):
  77. return {
  78. 'user_id': user_id,
  79. 'tenant_no': 'tenant-a',
  80. 'role': 'super_admin',
  81. 'region_id': 'dept-from-auth',
  82. }
  83. def _cell(db, row_id, *, sales='u1', manager='manager-a', region='region-a', created=None):
  84. row = BizCollectionProtocol(
  85. id=row_id,
  86. protocol_no=f'CELL-{row_id}',
  87. protocol_status='dsx',
  88. tenant_no='tenant-a',
  89. create_dept_id='dept-a',
  90. create_time=created or datetime.now(),
  91. del_flag=0,
  92. )
  93. collection = BizProtocolCollectionInfo(
  94. id=f'ci-{row_id}',
  95. collection_protocol_id=row_id,
  96. region_id=region,
  97. sales_user_id=sales,
  98. region_manager_user_id=manager,
  99. tenant_no='tenant-a',
  100. create_dept_id='dept-a',
  101. del_flag=0,
  102. )
  103. db.add_all([row, collection])
  104. return row
  105. def _donation(db, row_id, *, sales='u1', manager='manager-a', region='region-a', created=None):
  106. row = BizResearchDonation(
  107. id=row_id,
  108. donation_no=f'DON-{row_id}',
  109. donation_status='dsx',
  110. tenant_no='tenant-a',
  111. create_dept_id='dept-a',
  112. create_time=created or datetime.now(),
  113. del_flag=0,
  114. )
  115. collection = BizResearchDonationCollection(
  116. id=f'dci-{row_id}',
  117. research_donation_id=row_id,
  118. region_id=region,
  119. sales_user_id=sales,
  120. region_manager_user_id=manager,
  121. tenant_no='tenant-a',
  122. create_dept_id='dept-a',
  123. del_flag=0,
  124. )
  125. db.add_all([row, collection])
  126. return row
  127. def _masters(db):
  128. db.add_all(
  129. [
  130. BizCollectionRegion(
  131. id='region-a', region_name='A区', tenant_no='tenant-a', create_dept_id='', del_flag=0
  132. ),
  133. BizCollectionRegion(
  134. id='region-b', region_name='B区', tenant_no='tenant-a', create_dept_id='', del_flag=0
  135. ),
  136. BizCollectionHospital(
  137. id='hospital-a', region_id='region-a', hospital_name='A院', tenant_no='tenant-a', create_dept_id='', del_flag=0
  138. ),
  139. BizCellSampleType(
  140. id='type-a', type_code='A', type_name='样本A', tenant_no='tenant-a', create_dept_id='', del_flag=0
  141. ),
  142. BizSampleCode(
  143. id='code-a', sample_code='S-001', tenant_no='tenant-a', create_dept_id='', del_flag=0
  144. ),
  145. BizSampleCode(
  146. id='code-cross', sample_code='S-CROSS', tenant_no='tenant-b', create_dept_id='', del_flag=0
  147. ),
  148. BizSampleCode(
  149. id='code-deleted', sample_code='S-DELETED', tenant_no='tenant-a', create_dept_id='', del_flag=1
  150. ),
  151. BizItemUnit(
  152. id='unit-a', unit_code='ml', unit_name='毫升', tenant_no='tenant-a', create_dept_id='', del_flag=0
  153. ),
  154. SysUser(
  155. id='u1', user_no='u1', nick_name='当前用户', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
  156. ),
  157. SysUser(
  158. id='manager-a', user_no='manager-a', nick_name='经理', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
  159. ),
  160. SysUser(
  161. id='u2', user_no='u2', nick_name='其他专员', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
  162. ),
  163. BizAttachment(
  164. id='att-protocol-a',
  165. business_type='market:specialist:protocol:staging',
  166. original_name='a.pdf',
  167. file_ext='pdf',
  168. file_url='protocol/a.pdf',
  169. file_size=10,
  170. file_type='application/pdf',
  171. tenant_no='tenant-a',
  172. create_by='u1',
  173. create_dept_id='',
  174. del_flag=0,
  175. ),
  176. ]
  177. )
  178. db.commit()
  179. def _body(source='cell_storage', **overrides):
  180. from app.schemas.market_mobile import MarketProtocolBody
  181. payload = {
  182. 'source': source,
  183. 'protocolNo': 'P-001',
  184. 'registerDate': '20261010',
  185. 'attachments': {'protocolAttachments': 'protocol/a.pdf'},
  186. 'customer': {'motherName': '张三', 'motherPhone': '13800000000'},
  187. 'collection': {
  188. 'regionId': 'region-a',
  189. 'hospitalId': 'hospital-a',
  190. 'salesUserId': 'someone-else',
  191. 'regionManagerUserId': 'manager-a',
  192. },
  193. 'samples': [
  194. {
  195. 'sampleTypeId': 'type-a',
  196. 'sampleCodeId': 'code-a',
  197. 'sampleCode': 'S-001',
  198. 'unitId': 'unit-a',
  199. 'collectAmount': 10,
  200. }
  201. ],
  202. 'remark': '移动端登记',
  203. }
  204. payload.update(overrides)
  205. return MarketProtocolBody.model_validate(payload)
  206. def test_protocol_create_rejects_unknown_attachment_reference():
  207. from app.services.market_protocol import create_protocol
  208. db = _session()
  209. _masters(db)
  210. body = _body(
  211. attachments={'protocolAttachments': 'https://evil.test/a.pdf'}
  212. )
  213. with pytest.raises(ValueError, match='附件'):
  214. create_protocol(
  215. db,
  216. body=body,
  217. ctx=_ctx('specialist'),
  218. user_info=_user(),
  219. )
  220. def test_terminal_protocol_rejects_hidden_update_action():
  221. from app.services.market_protocol import update_protocol
  222. db = _session()
  223. _masters(db)
  224. row = _cell(db, 'terminal')
  225. row.protocol_status = 'yzf'
  226. db.commit()
  227. with pytest.raises(ValueError, match='不可修改'):
  228. update_protocol(
  229. db,
  230. source='cell_storage',
  231. protocol_id=row.id,
  232. body=_body(protocolNo='CELL-terminal'),
  233. ctx=_ctx('specialist'),
  234. user_info=_user(),
  235. )
  236. def test_protocol_list_combines_cell_and_donation_without_scope_leak():
  237. from app.services.market_protocol import list_protocols
  238. db = _session()
  239. now = datetime.now()
  240. _cell(db, 'cell-own', created=now - timedelta(minutes=2))
  241. _cell(db, 'cell-other', sales='u2', created=now)
  242. _donation(db, 'don-own', created=now - timedelta(minutes=1))
  243. _donation(db, 'don-other', sales='u2', created=now + timedelta(minutes=1))
  244. db.commit()
  245. items, total = list_protocols(db, ctx=_ctx('specialist'), user_info=_user())
  246. assert total == 2
  247. assert [(item['source'], item['id']) for item in items] == [
  248. ('research_donation', 'don-own'),
  249. ('cell_storage', 'cell-own'),
  250. ]
  251. def test_protocol_list_large_page_uses_bounded_source_queries_and_global_order():
  252. from app.services.market_protocol import list_protocols
  253. db = _session()
  254. base = datetime(2026, 10, 10, 12, 0, 0)
  255. expected = []
  256. for index in range(24):
  257. created = base + timedelta(seconds=index)
  258. cell_id = f'cell-{index:02d}'
  259. donation_id = f'don-{index:02d}'
  260. _cell(db, cell_id, created=created)
  261. _donation(db, donation_id, created=created + timedelta(microseconds=1))
  262. expected.extend([(created, cell_id), (created + timedelta(microseconds=1), donation_id)])
  263. db.commit()
  264. statements = []
  265. def capture(_conn, _cursor, statement, _parameters, _context, _many):
  266. if 'FROM biz_collection_protocol ' in statement or 'FROM biz_research_donation ' in statement:
  267. statements.append(statement)
  268. event.listen(db.get_bind(), 'before_cursor_execute', capture)
  269. try:
  270. items, total = list_protocols(
  271. db,
  272. ctx=_ctx('specialist'),
  273. user_info=_user(),
  274. page=4,
  275. page_size=7,
  276. )
  277. finally:
  278. event.remove(db.get_bind(), 'before_cursor_execute', capture)
  279. ordered_ids = [item[1] for item in sorted(expected, key=lambda item: (item[0], item[1]), reverse=True)]
  280. assert total == 48
  281. assert [item['id'] for item in items] == ordered_ids[21:28]
  282. page_selects = [sql for sql in statements if 'count(' not in sql.lower()]
  283. assert len(page_selects) == 2
  284. assert all(' LIMIT ' in sql.upper() for sql in page_selects)
  285. def test_protocol_list_stable_tie_breaker_prevents_page_overlap():
  286. from app.services.market_protocol import list_protocols
  287. db = _session()
  288. same_time = datetime(2026, 10, 10, 12, 0, 0)
  289. for row_id in ('a1', 'a3', 'a5', 'a7'):
  290. _cell(db, row_id, created=same_time)
  291. for row_id in ('a2', 'a4', 'a6', 'a8'):
  292. _donation(db, row_id, created=same_time)
  293. db.commit()
  294. page_one, total = list_protocols(
  295. db, ctx=_ctx('specialist'), user_info=_user(), page=1, page_size=3
  296. )
  297. page_two, _ = list_protocols(
  298. db, ctx=_ctx('specialist'), user_info=_user(), page=2, page_size=3
  299. )
  300. assert total == 8
  301. assert [item['id'] for item in page_one] == ['a8', 'a7', 'a6']
  302. assert [item['id'] for item in page_two] == ['a5', 'a4', 'a3']
  303. assert {item['id'] for item in page_one}.isdisjoint(
  304. {item['id'] for item in page_two}
  305. )
  306. def test_protocol_detail_rechecks_source_specific_idor():
  307. from app.services.market_protocol import get_protocol
  308. db = _session()
  309. _cell(db, 'cell-other', sales='u2')
  310. _donation(db, 'don-other', sales='u2')
  311. db.commit()
  312. with pytest.raises(HTTPException) as cell_error:
  313. get_protocol(
  314. db,
  315. source='cell_storage',
  316. protocol_id='cell-other',
  317. ctx=_ctx('specialist'),
  318. user_info=_user(),
  319. )
  320. with pytest.raises(HTTPException) as donation_error:
  321. get_protocol(
  322. db,
  323. source='research_donation',
  324. protocol_id='don-other',
  325. ctx=_ctx('specialist'),
  326. user_info=_user(),
  327. )
  328. assert cell_error.value.status_code == 404
  329. assert donation_error.value.status_code == 404
  330. def test_protocol_detail_can_resolve_source_from_scoped_record():
  331. from app.services.market_protocol import get_protocol
  332. db = _session()
  333. _donation(db, 'don-own')
  334. db.commit()
  335. result = get_protocol(
  336. db,
  337. source=None,
  338. protocol_id='don-own',
  339. ctx=_ctx('specialist'),
  340. user_info=_user(),
  341. )
  342. assert result['source'] == 'research_donation'
  343. assert result['basic']['id'] == 'don-own'
  344. def test_specialist_cell_create_forces_current_collection_and_collector_users():
  345. from app.services.market_protocol import create_protocol
  346. db = _session()
  347. _masters(db)
  348. result = create_protocol(
  349. db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
  350. )
  351. main = db.get(BizCollectionProtocol, result['basic']['id'])
  352. collection = db.query(BizProtocolCollectionInfo).filter_by(collection_protocol_id=main.id, del_flag=0).one()
  353. party = db.query(BizProtocolPartyCustomer).filter_by(collection_protocol_id=main.id, del_flag=0).one()
  354. sample = db.query(BizProtocolSample).filter_by(collection_protocol_id=main.id, del_flag=0).one()
  355. assert main.collector_user_id == 'u1'
  356. assert main.create_dept_id == 'dept-from-auth'
  357. assert collection.sales_user_id == 'u1'
  358. assert collection.create_dept_id == 'dept-from-auth'
  359. assert party.collector_user_id == 'u1'
  360. assert party.customer_id is None
  361. assert db.query(BizCustomer).count() == 0
  362. assert sample.sample_code == 'S-001'
  363. def test_specialist_donation_create_forces_current_users_and_has_no_payment_section():
  364. from app.services.market_protocol import create_protocol
  365. db = _session()
  366. _masters(db)
  367. result = create_protocol(
  368. db,
  369. body=_body('research_donation', donationNo='D-001', protocolNo=None),
  370. ctx=_ctx('specialist'),
  371. user_info=_user(),
  372. )
  373. donation = db.get(BizResearchDonation, result['basic']['id'])
  374. collection = db.query(BizResearchDonationCollection).filter_by(research_donation_id=donation.id, del_flag=0).one()
  375. party = db.query(BizResearchDonationParty).filter_by(research_donation_id=donation.id, del_flag=0).one()
  376. sample = db.query(BizResearchDonationSample).filter_by(research_donation_id=donation.id, del_flag=0).one()
  377. assert donation.collector_user_id == 'u1'
  378. assert donation.create_dept_id == 'dept-from-auth'
  379. assert collection.sales_user_id == 'u1'
  380. assert collection.create_dept_id == 'dept-from-auth'
  381. assert party.collector_user_id == 'u1'
  382. assert party.create_dept_id == 'dept-from-auth'
  383. assert sample.create_dept_id == 'dept-from-auth'
  384. assert party.customer_id is None
  385. assert db.query(BizCustomer).count() == 0
  386. assert 'payment' not in result
  387. def test_manager_update_cannot_move_document_out_of_scope_and_rolls_back():
  388. from app.services.market_protocol import update_protocol
  389. db = _session()
  390. _masters(db)
  391. _cell(db, 'cell-manager', manager='u1')
  392. db.commit()
  393. with pytest.raises(ValueError, match='范围'):
  394. update_protocol(
  395. db,
  396. source='cell_storage',
  397. protocol_id='cell-manager',
  398. body=_body(collection={'regionId': 'region-b', 'regionManagerUserId': 'manager-a'}),
  399. ctx=_ctx('manager'),
  400. user_info=_user(),
  401. )
  402. db.expire_all()
  403. collection = db.query(BizProtocolCollectionInfo).filter_by(collection_protocol_id='cell-manager', del_flag=0).one()
  404. assert collection.region_id == 'region-a'
  405. assert collection.region_manager_user_id == 'u1'
  406. def test_partial_collection_update_validates_against_resulting_region():
  407. from app.schemas.market_mobile import MarketProtocolBody
  408. from app.services.market_protocol import update_protocol
  409. db = _session()
  410. _masters(db)
  411. _cell(db, 'cell-partial', sales='u1')
  412. db.commit()
  413. update_protocol(
  414. db,
  415. source='cell_storage',
  416. protocol_id='cell-partial',
  417. body=MarketProtocolBody.model_validate(
  418. {
  419. 'source': 'cell_storage',
  420. 'collection': {'hospitalId': 'hospital-a'},
  421. }
  422. ),
  423. ctx=_ctx('specialist'),
  424. user_info=_user(),
  425. )
  426. collection = db.query(BizProtocolCollectionInfo).filter_by(
  427. collection_protocol_id='cell-partial', del_flag=0
  428. ).one()
  429. assert collection.region_id == 'region-a'
  430. assert collection.hospital_id == 'hospital-a'
  431. def test_cell_partial_customer_update_preserves_ids_and_unspecified_fields():
  432. from app.schemas.market_mobile import MarketProtocolBody
  433. from app.services.market_protocol import update_protocol
  434. db = _session()
  435. _masters(db)
  436. _cell(db, 'cell-party')
  437. db.add_all(
  438. [
  439. BizCustomer(id='mother-c', customer_name='母客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  440. BizCustomer(id='father-c', customer_name='父客户', sex='0', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  441. BizCustomer(id='newborn-c', customer_name='子客户', sex='2', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  442. BizProtocolPartyCustomer(
  443. id='party-cell', collection_protocol_id='cell-party',
  444. customer_id='mother-c', father_customer_id='father-c', newborn_customer_id='newborn-c',
  445. mother_name='母亲', mother_phone='旧电话', mother_id_card='ID-M',
  446. father_name='父亲', father_phone='父电话', newborn_name='宝宝',
  447. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  448. ),
  449. ]
  450. )
  451. db.commit()
  452. update_protocol(
  453. db,
  454. source='cell_storage',
  455. protocol_id='cell-party',
  456. body=MarketProtocolBody.model_validate(
  457. {
  458. 'source': 'cell_storage',
  459. 'customer': {'motherPhone': '新电话', 'remark': '只改电话'},
  460. }
  461. ),
  462. ctx=_ctx('specialist'),
  463. user_info=_user(),
  464. )
  465. party = db.get(BizProtocolPartyCustomer, 'party-cell')
  466. assert (party.customer_id, party.father_customer_id, party.newborn_customer_id) == (
  467. 'mother-c', 'father-c', 'newborn-c'
  468. )
  469. assert party.mother_name == '母亲'
  470. assert party.mother_id_card == 'ID-M'
  471. assert party.father_name == '父亲'
  472. assert party.father_phone == '父电话'
  473. assert party.newborn_name == '宝宝'
  474. assert party.mother_phone == '新电话'
  475. assert party.remark == '只改电话'
  476. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  477. def test_protocol_update_rejects_same_tenant_unlinked_customer_id(source):
  478. from app.schemas.market_mobile import MarketProtocolBody
  479. from app.services.market_protocol import update_protocol
  480. db = _session()
  481. _masters(db)
  482. db.add_all(
  483. [
  484. BizCustomer(id='linked-c', customer_name='已关联', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  485. BizCustomer(id='unrelated-c', customer_name='无关客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  486. ]
  487. )
  488. if source == 'cell_storage':
  489. _cell(db, 'customer-idor')
  490. db.add(BizProtocolPartyCustomer(id='linked-party', collection_protocol_id='customer-idor', customer_id='linked-c', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  491. else:
  492. _donation(db, 'customer-idor')
  493. db.add(BizResearchDonationParty(id='linked-party', research_donation_id='customer-idor', customer_id='linked-c', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  494. db.commit()
  495. with pytest.raises(ValueError, match='客户'):
  496. update_protocol(
  497. db,
  498. source=source,
  499. protocol_id='customer-idor',
  500. body=MarketProtocolBody.model_validate(
  501. {
  502. 'source': source,
  503. 'customer': {'customerId': 'unrelated-c', 'motherName': '被篡改'},
  504. }
  505. ),
  506. ctx=_ctx('specialist'),
  507. user_info=_user(),
  508. )
  509. db.expire_all()
  510. assert db.get(BizCustomer, 'unrelated-c').customer_name == '无关客户'
  511. assert db.get(BizCustomer, 'linked-c').customer_name == '已关联'
  512. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  513. def test_protocol_update_does_not_mutate_unrelated_customer_by_id_card(source):
  514. from app.schemas.market_mobile import MarketProtocolBody
  515. from app.services.market_protocol import update_protocol
  516. db = _session()
  517. _masters(db)
  518. db.add(BizCustomer(id='unrelated-card', customer_name='身份证客户', sex='1', id_card_no='CARD-1', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  519. if source == 'cell_storage':
  520. _cell(db, 'card-idor')
  521. db.add(BizProtocolPartyCustomer(id='card-party', collection_protocol_id='card-idor', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  522. else:
  523. _donation(db, 'card-idor')
  524. db.add(BizResearchDonationParty(id='card-party', research_donation_id='card-idor', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  525. db.commit()
  526. update_protocol(
  527. db,
  528. source=source,
  529. protocol_id='card-idor',
  530. body=MarketProtocolBody.model_validate(
  531. {
  532. 'source': source,
  533. 'customer': {'motherName': '协议姓名', 'motherIdCard': 'CARD-1'},
  534. }
  535. ),
  536. ctx=_ctx('specialist'),
  537. user_info=_user(),
  538. )
  539. db.expire_all()
  540. assert db.get(BizCustomer, 'unrelated-card').customer_name == '身份证客户'
  541. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  542. def test_protocol_create_forbids_existing_customer_ids(source):
  543. from app.services.market_protocol import create_protocol
  544. db = _session()
  545. _masters(db)
  546. db.add(BizCustomer(id='existing-c', customer_name='已有客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  547. db.commit()
  548. with pytest.raises(ValueError, match='客户'):
  549. create_protocol(
  550. db,
  551. body=_body(
  552. source,
  553. **({'donationNo': 'D-CUSTOMER', 'protocolNo': None} if source == 'research_donation' else {}),
  554. customer={'customerId': 'existing-c', 'motherName': '已有客户'},
  555. ),
  556. ctx=_ctx('specialist'),
  557. user_info=_user(),
  558. )
  559. def test_protocol_body_rejects_status_finance_and_conversion_fields():
  560. from app.schemas.market_mobile import MarketProtocolBody
  561. forbidden = [
  562. {'auditStatus': 'shtg'},
  563. {'protocolStatus': 'yzf'},
  564. {'delFlag': 1},
  565. {'refundAmount': 100},
  566. {'financeRemark': '通过'},
  567. {'toResearchTime': '2026-10-10'},
  568. ]
  569. for field in forbidden:
  570. with pytest.raises(ValidationError):
  571. MarketProtocolBody.model_validate({'source': 'cell_storage', **field})
  572. def test_cross_tenant_or_disabled_referenced_user_is_rejected():
  573. from app.services.market_protocol import create_protocol
  574. db = _session()
  575. _masters(db)
  576. db.add(
  577. SysUser(
  578. id='disabled', user_no='disabled', nick_name='停用', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0
  579. )
  580. )
  581. db.commit()
  582. with pytest.raises(ValueError, match='用户'):
  583. create_protocol(
  584. db,
  585. body=_body(collection={'regionId': 'region-a', 'hospitalId': 'hospital-a', 'regionManagerUserId': 'disabled'}),
  586. ctx=_ctx('specialist'),
  587. user_info=_user(),
  588. )
  589. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  590. @pytest.mark.parametrize(
  591. ('sample_code_id', 'sample_code'),
  592. [
  593. ('code-cross', 'S-CROSS'),
  594. ('code-deleted', 'S-DELETED'),
  595. ('code-a', 'S-WRONG'),
  596. ],
  597. )
  598. def test_protocol_rejects_invalid_or_inconsistent_sample_code_reference(
  599. source, sample_code_id, sample_code
  600. ):
  601. from app.services.market_protocol import create_protocol
  602. db = _session()
  603. _masters(db)
  604. payload = {
  605. 'samples': [
  606. {
  607. 'sampleTypeId': 'type-a',
  608. 'sampleCodeId': sample_code_id,
  609. 'sampleCode': sample_code,
  610. 'unitId': 'unit-a',
  611. }
  612. ]
  613. }
  614. if source == 'research_donation':
  615. payload.update({'donationNo': 'D-CODE', 'protocolNo': None})
  616. with pytest.raises(ValueError, match='样本编码'):
  617. create_protocol(
  618. db,
  619. body=_body(source, **payload),
  620. ctx=_ctx('specialist'),
  621. user_info=_user(),
  622. )
  623. def test_cell_update_rolls_back_main_and_existing_children_after_late_failure():
  624. from app.schemas.market_mobile import MarketProtocolBody
  625. from app.services.market_protocol import update_protocol
  626. db = _session()
  627. _masters(db)
  628. _cell(db, 'cell-update')
  629. db.add_all(
  630. [
  631. BizProtocolPartyCustomer(
  632. id='party-old', collection_protocol_id='cell-update', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
  633. ),
  634. BizProtocolSample(
  635. id='sample-old', collection_protocol_id='cell-update', sample_code='OLD-CODE', tenant_no='tenant-a', create_dept_id='', del_flag=0
  636. ),
  637. ]
  638. )
  639. db.commit()
  640. body = MarketProtocolBody.model_validate(
  641. {
  642. 'source': 'cell_storage',
  643. 'remark': '新备注',
  644. 'customer': {'motherName': '新姓名'},
  645. 'collection': {'medicalStaff': '新医护'},
  646. 'samples': [{'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'}],
  647. }
  648. )
  649. with patch(
  650. 'app.services.market_protocol.cell_crud.save_samples',
  651. side_effect=RuntimeError('late failure'),
  652. ):
  653. with pytest.raises(RuntimeError, match='late'):
  654. update_protocol(
  655. db,
  656. source='cell_storage',
  657. protocol_id='cell-update',
  658. body=body,
  659. ctx=_ctx('specialist'),
  660. user_info=_user(),
  661. )
  662. db.expire_all()
  663. assert db.get(BizCollectionProtocol, 'cell-update').remark is None
  664. assert db.get(BizProtocolPartyCustomer, 'party-old').mother_name == '旧姓名'
  665. assert db.get(BizProtocolPartyCustomer, 'party-old').del_flag == 0
  666. assert db.get(BizProtocolSample, 'sample-old').sample_code == 'OLD-CODE'
  667. assert db.get(BizProtocolCollectionInfo, 'ci-cell-update').medical_staff is None
  668. def test_donation_update_rolls_back_main_and_old_children_after_soft_delete():
  669. from app.schemas.market_mobile import MarketProtocolBody
  670. from app.services.market_protocol import update_protocol
  671. db = _session()
  672. _masters(db)
  673. _donation(db, 'don-update')
  674. db.add_all(
  675. [
  676. BizResearchDonationParty(
  677. id='dparty-old', research_donation_id='don-update', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
  678. ),
  679. BizResearchDonationSample(
  680. id='dsample-old', research_donation_id='don-update', sample_code='OLD-CODE', tenant_no='tenant-a', create_dept_id='', del_flag=0
  681. ),
  682. ]
  683. )
  684. db.commit()
  685. body = MarketProtocolBody.model_validate(
  686. {
  687. 'source': 'research_donation',
  688. 'donationNo': 'DON-don-update',
  689. 'remark': '新备注',
  690. 'customer': {'motherName': '新姓名'},
  691. 'collection': {'medicalStaff': '新医护'},
  692. 'samples': [{'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'}],
  693. }
  694. )
  695. with patch(
  696. 'app.crud.research_donation._create_samples',
  697. side_effect=RuntimeError('late failure'),
  698. ):
  699. with pytest.raises(RuntimeError, match='late'):
  700. update_protocol(
  701. db,
  702. source='research_donation',
  703. protocol_id='don-update',
  704. body=body,
  705. ctx=_ctx('specialist'),
  706. user_info=_user(),
  707. )
  708. db.expire_all()
  709. assert db.get(BizResearchDonation, 'don-update').remark is None
  710. assert db.get(BizResearchDonationParty, 'dparty-old').mother_name == '旧姓名'
  711. assert db.get(BizResearchDonationParty, 'dparty-old').del_flag == 0
  712. assert db.get(BizResearchDonationSample, 'dsample-old').del_flag == 0
  713. assert db.get(BizResearchDonationCollection, 'dci-don-update').medical_staff is None
  714. def test_manager_can_update_donation_in_scope_but_cannot_move_it_out():
  715. from app.schemas.market_mobile import MarketProtocolBody
  716. from app.services.market_protocol import update_protocol
  717. db = _session()
  718. _masters(db)
  719. _donation(db, 'don-manager', sales='u2', manager='u1')
  720. db.add(
  721. BizResearchDonationParty(
  722. id='dparty-manager', research_donation_id='don-manager', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
  723. )
  724. )
  725. db.commit()
  726. result = update_protocol(
  727. db,
  728. source='research_donation',
  729. protocol_id='don-manager',
  730. body=MarketProtocolBody.model_validate(
  731. {
  732. 'source': 'research_donation',
  733. 'donationNo': 'DON-don-manager',
  734. 'remark': '经理已修改',
  735. 'customer': {'motherName': '新姓名'},
  736. 'collection': {'medicalStaff': '新医护'},
  737. }
  738. ),
  739. ctx=_ctx('manager'),
  740. user_info=_user(),
  741. )
  742. assert result['basic']['remark'] == '经理已修改'
  743. assert result['customer']['motherName'] == '新姓名'
  744. assert result['collection']['medicalStaff'] == '新医护'
  745. with pytest.raises(ValueError, match='范围'):
  746. update_protocol(
  747. db,
  748. source='research_donation',
  749. protocol_id='don-manager',
  750. body=MarketProtocolBody.model_validate(
  751. {
  752. 'source': 'research_donation',
  753. 'collection': {
  754. 'regionId': 'region-b',
  755. 'regionManagerUserId': 'manager-a',
  756. },
  757. }
  758. ),
  759. ctx=_ctx('manager'),
  760. user_info=_user(),
  761. )
  762. db.expire_all()
  763. collection = db.query(BizResearchDonationCollection).filter_by(
  764. research_donation_id='don-manager', del_flag=0
  765. ).one()
  766. assert collection.region_id == 'region-a'
  767. assert collection.region_manager_user_id == 'u1'
  768. def test_protocol_routes_do_not_expose_audit_void_refund_or_conversion():
  769. from app.api.market_mobile import router
  770. paths = {route.path for route in router.routes}
  771. disallowed = ('audit', 'void', 'refund', 'convert', 'to-research', 'to-storage')
  772. protocol_paths = [path for path in paths if '/protocols' in path]
  773. assert protocol_paths
  774. assert all(not any(word in path for word in disallowed) for path in protocol_paths)
  775. def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
  776. from app.services.market_protocol import create_protocol
  777. db = _session()
  778. _masters(db)
  779. with patch(
  780. 'app.services.market_protocol.cell_crud.upsert_collection_info',
  781. side_effect=RuntimeError('injected child failure'),
  782. ):
  783. with pytest.raises(RuntimeError, match='injected'):
  784. create_protocol(
  785. db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
  786. )
  787. assert db.query(BizCollectionProtocol).count() == 0
  788. assert db.query(BizProtocolPartyCustomer).count() == 0
  789. assert db.query(BizCustomer).count() == 0
  790. def test_donation_create_rolls_back_customer_and_children_on_child_failure():
  791. from app.services.market_protocol import create_protocol
  792. db = _session()
  793. _masters(db)
  794. with patch(
  795. 'app.crud.research_donation._create_collection',
  796. side_effect=RuntimeError('injected child failure'),
  797. ):
  798. with pytest.raises(RuntimeError, match='injected'):
  799. create_protocol(
  800. db,
  801. body=_body(
  802. 'research_donation', donationNo='D-ROLLBACK', protocolNo=None
  803. ),
  804. ctx=_ctx('specialist'),
  805. user_info=_user(),
  806. )
  807. assert db.query(BizResearchDonation).count() == 0
  808. assert db.query(BizResearchDonationParty).count() == 0
  809. assert db.query(BizCustomer).count() == 0