test_market_freight_scope.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011
  1. """市场移动端运费批次:作用域、脱敏与原子写规则。"""
  2. from datetime import datetime, timedelta
  3. from decimal import Decimal
  4. from pathlib import Path
  5. import pytest
  6. from fastapi import HTTPException
  7. from pydantic import ValidationError
  8. from sqlalchemy import create_engine
  9. from sqlalchemy.dialects import mysql
  10. from sqlalchemy.orm import sessionmaker
  11. from app.models.base import Base
  12. from app.models.models import (
  13. BizCellSampleType, BizCollectionProtocol, BizCollectionRegionStaff, BizProtocolCollectionInfo,
  14. BizProtocolPartyCustomer, BizProtocolSample, BizSampleFreight,
  15. BizSampleFreightItem, SysDictData, SysDictType, SysPost, SysPostRole,
  16. SysDept, SysRole, SysRoleDataScopeDetail, SysUser, SysUserPost, SysUserRole,
  17. )
  18. from app.models.research_donation import (
  19. BizResearchDonation, BizResearchDonationCollection,
  20. BizResearchDonationParty, BizResearchDonationSample,
  21. )
  22. from app.services.market_field_scope import MarketFieldContext
  23. TABLES = [
  24. BizCollectionProtocol.__table__, BizProtocolCollectionInfo.__table__,
  25. BizProtocolPartyCustomer.__table__, BizProtocolSample.__table__,
  26. BizResearchDonation.__table__, BizResearchDonationCollection.__table__,
  27. BizResearchDonationParty.__table__, BizResearchDonationSample.__table__,
  28. BizSampleFreight.__table__, BizSampleFreightItem.__table__,
  29. BizCellSampleType.__table__,
  30. BizCollectionRegionStaff.__table__, SysDictType.__table__,
  31. SysDictData.__table__, SysUser.__table__, SysRole.__table__,
  32. SysRoleDataScopeDetail.__table__, SysUserRole.__table__, SysPost.__table__,
  33. SysPostRole.__table__, SysUserPost.__table__, SysDept.__table__,
  34. ]
  35. def _session():
  36. engine = create_engine('sqlite:///:memory:')
  37. Base.metadata.create_all(engine, tables=TABLES)
  38. db = sessionmaker(bind=engine)()
  39. now = datetime(2026, 10, 11, 8, 0)
  40. db.add_all([
  41. SysDictType(id='dt', dict_name='运输方式', dict_type='sample_transport_mode', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
  42. SysDictData(id='dd', dict_label='顺丰', dict_value='sf', dict_type='sample_transport_mode', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
  43. SysUser(id='u1', dept_id='', user_no='u1', nick_name='专员', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
  44. SysUser(id='m1', dept_id='', user_no='m1', nick_name='经理', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
  45. SysRole(id='fixture-super-role', role_name='超级管理员', data_scope='1', status='1', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
  46. SysUserRole(id='fixture-super-u1', user_id='u1', role_id='fixture-super-role', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
  47. SysUserRole(id='fixture-super-m1', user_id='m1', role_id='fixture-super-role', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
  48. ])
  49. db.commit()
  50. return db
  51. def _ctx(kind='specialist', user_id='u1'):
  52. return MarketFieldContext('tenant-a', user_id, kind)
  53. def _user(user_id='u1'):
  54. return {'user_id': user_id, 'tenant_no': 'tenant-a', 'role': 'super_admin'}
  55. def _sample(db, key, *, source='cell_storage', sales='u1', manager='m1', region='r1', tenant='tenant-a', deleted=0, created=None):
  56. created = created or datetime(2026, 10, 10, 8, 0)
  57. if source == 'cell_storage':
  58. parent = BizCollectionProtocol(id=f'cp-{key}', protocol_no=f'CP-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
  59. collection = BizProtocolCollectionInfo(id=f'cc-{key}', collection_protocol_id=parent.id, region_id=region, sales_user_id=sales, region_manager_user_id=manager, tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
  60. party = BizProtocolPartyCustomer(id=f'pc-{key}', collection_protocol_id=parent.id, mother_name=f'客户-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
  61. sample = BizProtocolSample(id=f'cs-{key}', collection_protocol_id=parent.id, sample_code=f'C-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
  62. else:
  63. parent = BizResearchDonation(id=f'rd-{key}', donation_no=f'RD-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
  64. collection = BizResearchDonationCollection(id=f'rc-{key}', research_donation_id=parent.id, region_id=region, sales_user_id=sales, region_manager_user_id=manager, tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
  65. party = BizResearchDonationParty(id=f'rp-{key}', research_donation_id=parent.id, mother_name=f'客户-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
  66. sample = BizResearchDonationSample(id=f'rs-{key}', research_donation_id=parent.id, sample_code=f'R-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
  67. db.add_all([parent, collection, party, sample])
  68. db.commit()
  69. return sample, parent
  70. def _freight(db, key, samples, *, tenant='tenant-a', amount='12.30', deleted=0, create_by='u1'):
  71. now = datetime(2026, 10, 11, 8, 0)
  72. freight = BizSampleFreight(id=f'f-{key}', freight_no=f'YF-{key}', transport_mode='sf', freight_amount=Decimal(amount), tenant_no=tenant, create_by=create_by, create_dept_id='', create_time=now, update_time=now, del_flag=deleted)
  73. db.add(freight)
  74. for pos, (source, sample, parent) in enumerate(samples):
  75. db.add(BizSampleFreightItem(id=f'fi-{key}-{pos}', freight_id=freight.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=tenant, create_dept_id='', create_time=now, update_time=now, del_flag=0))
  76. db.commit()
  77. return freight
  78. def _items(*pairs):
  79. return [{'sampleSource': source, 'sampleId': sample.id} for source, sample in pairs]
  80. def test_specialist_sees_shared_batch_but_only_own_items_and_count():
  81. from app.services.market_freight import get_freight_detail, list_freights
  82. db = _session()
  83. own, own_parent = _sample(db, 'own')
  84. other, other_parent = _sample(db, 'other', sales='u2')
  85. freight = _freight(db, 'mixed', [('cell_storage', own, own_parent), ('cell_storage', other, other_parent)])
  86. rows, total = list_freights(db, ctx=_ctx(), user_info=_user(), include_items=True)
  87. detail = get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())
  88. assert total == 1 and rows[0]['visibleItemCount'] == 1
  89. assert [item['sampleCode'] for item in rows[0]['items']] == ['C-own']
  90. assert [item['sampleCode'] for item in detail['items']] == ['C-own']
  91. assert rows[0]['createBy'] == detail['createBy'] == 'u1'
  92. assert rows[0]['createByName'] == detail['createByName'] == '专员'
  93. assert 'linkedSampleCount' not in rows[0] and 'linkedSampleCount' not in detail
  94. assert 'hasHiddenItems' not in detail
  95. def test_freight_creator_name_uses_historical_nickname_without_id_fallback():
  96. from app.services.market_freight import get_freight_detail, list_freights
  97. db = _session()
  98. now = datetime(2026, 10, 11, 8, 0)
  99. db.add_all(
  100. [
  101. SysUser(id='deleted-creator', dept_id='', user_no='deleted-no', nick_name='历史创建人', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=1),
  102. SysUser(id='blank-creator', dept_id='', user_no='raw-creator-no', nick_name='', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=1),
  103. ]
  104. )
  105. first_sample, first_parent = _sample(db, 'creator-history')
  106. second_sample, second_parent = _sample(db, 'creator-blank')
  107. historical = _freight(
  108. db,
  109. 'creator-history',
  110. [('cell_storage', first_sample, first_parent)],
  111. create_by='deleted-creator',
  112. )
  113. blank = _freight(
  114. db,
  115. 'creator-blank',
  116. [('cell_storage', second_sample, second_parent)],
  117. create_by='blank-creator',
  118. )
  119. items, total = list_freights(db, ctx=_ctx(), user_info=_user())
  120. by_id = {item['id']: item for item in items}
  121. historical_detail = get_freight_detail(
  122. db, historical.id, ctx=_ctx(), user_info=_user()
  123. )
  124. blank_detail = get_freight_detail(
  125. db, blank.id, ctx=_ctx(), user_info=_user()
  126. )
  127. assert total == 2
  128. assert by_id[historical.id]['createByName'] == '历史创建人'
  129. assert historical_detail['createByName'] == '历史创建人'
  130. assert by_id[blank.id]['createByName'] == ''
  131. assert blank_detail['createByName'] == ''
  132. def test_batch_without_visible_item_is_listed_and_linkable_like_web():
  133. """列表与 Web 一致:按运费单数据权限展示,不再要求现场可见明细。"""
  134. from app.services.market_freight import get_freight_detail, link_samples, list_freights
  135. db = _session()
  136. other, parent = _sample(db, 'other', sales='u2')
  137. own, own_parent = _sample(db, 'own')
  138. freight = _freight(db, 'hidden', [('cell_storage', other, parent)])
  139. rows, total = list_freights(db, ctx=_ctx(), user_info=_user(), include_items=True)
  140. detail = get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())
  141. linked = link_samples(
  142. db,
  143. freight.id,
  144. ctx=_ctx(),
  145. user_info=_user(),
  146. items=_items(('cell_storage', own)),
  147. )
  148. assert total == 1
  149. assert rows[0]['id'] == freight.id
  150. assert rows[0]['items'] == []
  151. assert detail['id'] == freight.id
  152. assert detail['items'] == []
  153. assert detail['visibleItemCount'] == 0
  154. assert linked['visibleItemCount'] == 1
  155. assert [item['sampleCode'] for item in linked['items']] == ['C-own']
  156. def test_keyword_and_source_filters_match_any_batch_items_like_web():
  157. from app.services.market_freight import list_freights
  158. db = _session()
  159. own, op = _sample(db, 'own')
  160. secret, sp = _sample(db, 'SECRET', source='research_donation', sales='u2')
  161. _freight(db, 'mixed', [('cell_storage', own, op), ('research_donation', secret, sp)])
  162. assert list_freights(db, ctx=_ctx(), user_info=_user(), keyword='SECRET')[1] == 1
  163. assert list_freights(db, ctx=_ctx(), user_info=_user(), sample_source='research_donation')[1] == 1
  164. assert list_freights(db, ctx=_ctx(), user_info=_user(), sample_source='cell_storage')[1] == 1
  165. def test_freight_list_filters_create_time_range():
  166. from app.services.market_freight import list_freights
  167. db = _session()
  168. first, first_parent = _sample(db, 'created-in-range')
  169. second, second_parent = _sample(db, 'created-outside')
  170. in_range = _freight(
  171. db,
  172. 'created-in-range',
  173. [('cell_storage', first, first_parent)],
  174. )
  175. outside = _freight(
  176. db,
  177. 'created-outside',
  178. [('cell_storage', second, second_parent)],
  179. )
  180. in_range.create_time = datetime(2026, 10, 11, 8, 0)
  181. outside.create_time = datetime(2026, 9, 1, 8, 0)
  182. db.commit()
  183. items, total = list_freights(
  184. db,
  185. ctx=_ctx(),
  186. user_info=_user(),
  187. create_time_start='2026-10-01',
  188. create_time_end='2026-10-13',
  189. )
  190. assert total == 1
  191. assert [item['id'] for item in items] == [in_range.id]
  192. def test_manager_scope_is_region_union_document_manager_for_both_sources():
  193. from app.services.market_freight import get_freight_detail
  194. db = _session()
  195. db.add(BizCollectionRegionStaff(id='staff', region_id='managed', user_id='m1', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0))
  196. db.commit()
  197. region_sample, rp = _sample(db, 'region', sales='u9', manager='x', region='managed')
  198. assigned, ap = _sample(db, 'assigned', source='research_donation', sales='u9', manager='m1', region='other')
  199. hidden, hp = _sample(db, 'hidden', sales='m1', manager='x', region='other')
  200. freight = _freight(db, 'manager', [('cell_storage', region_sample, rp), ('research_donation', assigned, ap), ('cell_storage', hidden, hp)])
  201. detail = get_freight_detail(db, freight.id, ctx=_ctx('manager', 'm1'), user_info=_user('m1'))
  202. assert {item['sampleCode'] for item in detail['items']} == {'C-region', 'R-assigned'}
  203. assert detail['visibleItemCount'] == 2
  204. assert all('sampleTypeName' in item and 'motherName' in item and 'newbornName' in item for item in detail['items'])
  205. def test_dual_role_list_uses_data_scope_not_field_scope():
  206. """列表与 Web 一致仅走数据权限;专员/经理路由不再按现场范围裁剪批次。"""
  207. from app.services.market_freight import list_freights
  208. db = _session()
  209. specialist, sp = _sample(db, 'specialist', sales='m1', manager='x', region='other')
  210. manager, mp = _sample(db, 'manager', sales='u9', manager='m1', region='other')
  211. _freight(db, 'specialist', [('cell_storage', specialist, sp)])
  212. _freight(db, 'manager', [('cell_storage', manager, mp)])
  213. specialist_rows, _ = list_freights(db, ctx=_ctx('specialist', 'm1'), user_info=_user('m1'))
  214. manager_rows, _ = list_freights(db, ctx=_ctx('manager', 'm1'), user_info=_user('m1'))
  215. assert {row['freightNo'] for row in specialist_rows} == {'YF-specialist', 'YF-manager'}
  216. assert {row['freightNo'] for row in manager_rows} == {'YF-specialist', 'YF-manager'}
  217. def test_cross_tenant_freight_is_invisible_while_orphan_item_batch_remains_listed():
  218. from app.services.market_freight import list_freights
  219. db = _session()
  220. cross, cp = _sample(db, 'cross', tenant='tenant-b')
  221. deleted, dp = _sample(db, 'deleted', deleted=1)
  222. _freight(db, 'cross', [('cell_storage', cross, cp)], tenant='tenant-b')
  223. orphan = _freight(db, 'deleted', [('cell_storage', deleted, dp)])
  224. rows, total = list_freights(db, ctx=_ctx(), user_info=_user())
  225. assert total == 1
  226. assert rows[0]['id'] == orphan.id
  227. def test_sample_options_are_scoped_sql_paginated_stable_and_derive_link_state():
  228. from app.services.market_freight import list_sample_options
  229. db = _session()
  230. base = datetime(2026, 10, 10, 8, 0)
  231. first, fp = _sample(db, 'first', created=base)
  232. _sample(db, 'second', source='research_donation', created=base + timedelta(hours=1))
  233. _sample(db, 'third', created=base + timedelta(hours=2))
  234. _sample(db, 'hidden', sales='u2', created=base + timedelta(hours=3))
  235. _freight(db, 'linked', [('cell_storage', first, fp)])
  236. page1, total = list_sample_options(db, ctx=_ctx(), user_info=_user(), page=1, page_size=2)
  237. page2, total2 = list_sample_options(db, ctx=_ctx(), user_info=_user(), page=2, page_size=2)
  238. assert total == total2 == 3
  239. assert [x['sampleCode'] for x in page1] == ['C-third', 'R-second']
  240. assert [x['sampleCode'] for x in page2] == ['C-first']
  241. assert page2[0]['freightId'] == 'f-linked'
  242. assert page2[0]['freightNo'] == 'YF-linked'
  243. assert set(('protocolNo', 'customerName', 'newbornName', 'sampleTypeName')).issubset(page2[0])
  244. def test_create_derives_snapshots_and_decimal_string_and_requires_visible_item():
  245. from app.services.market_freight import create_freight
  246. db = _session()
  247. own, parent = _sample(db, 'own')
  248. result = create_freight(db, ctx=_ctx(), user_info=_user(), transport_mode='sf', freight_amount=Decimal('12.30'), items=_items(('cell_storage', own)))
  249. item = db.query(BizSampleFreightItem).filter_by(freight_id=result['id']).one()
  250. assert result['freightAmount'] == '12.30'
  251. assert item.sample_code == 'C-own' and item.protocol_id == parent.id
  252. with pytest.raises(ValueError, match='至少'):
  253. create_freight(db, ctx=_ctx(), user_info=_user(), transport_mode='sf', freight_amount='1.00', items=[])
  254. def test_create_prevalidates_every_item_and_rolls_back_without_partial_batch():
  255. from app.services.market_freight import create_freight
  256. db = _session()
  257. own, _ = _sample(db, 'own')
  258. other, _ = _sample(db, 'other', sales='u2')
  259. before = db.query(BizSampleFreight).count()
  260. with pytest.raises(HTTPException):
  261. create_freight(db, ctx=_ctx(), user_info=_user(), transport_mode='sf', freight_amount='1.00', items=_items(('cell_storage', own), ('cell_storage', other)))
  262. assert db.query(BizSampleFreight).count() == before
  263. def test_link_rejects_hidden_and_existing_other_batch_without_silent_move():
  264. from app.services.market_freight import link_samples
  265. db = _session()
  266. own, op = _sample(db, 'own')
  267. other, _ = _sample(db, 'other', sales='u2')
  268. linked, lp = _sample(db, 'linked')
  269. target = _freight(db, 'target', [('cell_storage', own, op)])
  270. source = _freight(db, 'source', [('cell_storage', linked, lp)])
  271. for sample in (other, linked):
  272. with pytest.raises((HTTPException, ValueError)):
  273. link_samples(db, target.id, ctx=_ctx(), user_info=_user(), items=_items(('cell_storage', sample)))
  274. assert db.query(BizSampleFreightItem).filter_by(sample_id=linked.id, del_flag=0).one().freight_id == source.id
  275. def test_unlink_prevalidates_all_items_and_rejects_hidden_or_nonexistent():
  276. from app.services.market_freight import unlink_samples
  277. db = _session()
  278. own, op = _sample(db, 'own')
  279. other, hp = _sample(db, 'hidden', sales='u2')
  280. freight = _freight(db, 'mixed', [('cell_storage', own, op), ('cell_storage', other, hp)])
  281. missing, _ = _sample(db, 'missing')
  282. for sample in (other, missing):
  283. with pytest.raises((HTTPException, ValueError)):
  284. unlink_samples(db, freight.id, ctx=_ctx(), user_info=_user(), items=_items(('cell_storage', sample)))
  285. assert db.query(BizSampleFreightItem).filter_by(freight_id=freight.id, del_flag=0).count() == 2
  286. def test_unlink_last_visible_item_disappears_but_batch_remains_listed():
  287. from app.services.market_freight import list_freights, unlink_samples
  288. db = _session()
  289. own, op = _sample(db, 'own')
  290. other, hp = _sample(db, 'hidden', sales='u2')
  291. freight = _freight(db, 'mixed', [('cell_storage', own, op), ('cell_storage', other, hp)])
  292. result = unlink_samples(db, freight.id, ctx=_ctx(), user_info=_user(), items=_items(('cell_storage', own)))
  293. assert result == {'id': freight.id, 'visibleItemCount': 0, 'disappeared': True}
  294. assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 1
  295. assert db.query(BizSampleFreight).filter_by(id=freight.id).one().del_flag == 0
  296. @pytest.mark.parametrize('kind,user_id', [('specialist', 'u1'), ('manager', 'm1')])
  297. def test_shared_outside_scope_header_is_immutable_for_both_roles(kind, user_id):
  298. from app.services.market_freight import update_freight
  299. db = _session()
  300. visible, vp = _sample(db, 'visible', sales=user_id if kind == 'specialist' else 'u9', manager=user_id if kind == 'manager' else 'x')
  301. hidden, hp = _sample(db, 'hidden', sales='other', manager='other')
  302. freight = _freight(db, 'mixed', [('cell_storage', visible, vp), ('cell_storage', hidden, hp)])
  303. with pytest.raises(PermissionError, match='全部'):
  304. update_freight(db, freight.id, ctx=_ctx(kind, user_id), user_info=_user(user_id), freight_amount='99.00')
  305. assert db.query(BizSampleFreight).filter_by(id=freight.id).one().freight_amount == Decimal('12.30')
  306. def test_header_update_allowed_when_all_active_items_are_visible():
  307. from app.services.market_freight import update_freight
  308. db = _session()
  309. own, op = _sample(db, 'own')
  310. freight = _freight(db, 'own', [('cell_storage', own, op)])
  311. result = update_freight(
  312. db, freight.id, ctx=_ctx(), user_info=_user(),
  313. freight_amount='99.10', amount_supplied=True,
  314. )
  315. assert result['freightAmount'] == '99.10'
  316. @pytest.mark.parametrize('kind,user_id', [('specialist', 'u1'), ('manager', 'm1')])
  317. @pytest.mark.parametrize('mixed', [False, True])
  318. def test_detail_edit_action_requires_every_active_item_visible_without_leaking_totals(
  319. kind, user_id, mixed
  320. ):
  321. from app.services.market_freight import get_freight_detail
  322. from app.services.market_support import decorate_allowed_actions
  323. db = _session()
  324. visible, visible_parent = _sample(
  325. db,
  326. f'action-visible-{kind}-{mixed}',
  327. sales=user_id if kind == 'specialist' else 'other-sales',
  328. manager=user_id if kind == 'manager' else 'other-manager',
  329. )
  330. rows = [('cell_storage', visible, visible_parent)]
  331. if mixed:
  332. hidden, hidden_parent = _sample(
  333. db,
  334. f'action-hidden-{kind}',
  335. sales='hidden-sales',
  336. manager='hidden-manager',
  337. )
  338. rows.append(('cell_storage', hidden, hidden_parent))
  339. freight = _freight(db, f'action-{kind}-{mixed}', rows)
  340. detail = get_freight_detail(
  341. db,
  342. freight.id,
  343. ctx=_ctx(kind, user_id),
  344. user_info=_user(user_id),
  345. )
  346. rendered = decorate_allowed_actions(
  347. 'freight', detail, scope_kind=kind, detail=True
  348. )
  349. expected = ['link', 'unlink'] if mixed else ['edit', 'delete', 'link', 'unlink']
  350. assert detail['allowedActions'] == expected
  351. assert rendered['allowedActions'] == expected
  352. for hidden_key in (
  353. 'allItemsVisible',
  354. 'hiddenItemCount',
  355. 'totalItemCount',
  356. 'activeItemCount',
  357. ):
  358. assert hidden_key not in detail
  359. def test_dto_forbids_scope_snapshots_status_and_non_decimal_amounts():
  360. from app.schemas.market_mobile import MarketFreightCreateBody, MarketFreightItemsBody
  361. with pytest.raises(ValidationError):
  362. MarketFreightItemsBody.model_validate({'items': [{'sampleSource': 'cell_storage', 'sampleId': 's1', 'sampleCode': 'forged'}]})
  363. for extra in ('tenantNo', 'scope', 'status'):
  364. with pytest.raises(ValidationError):
  365. MarketFreightCreateBody.model_validate({'transportMode': 'sf', 'freightAmount': '1.00', 'items': [{'sampleSource': 'cell_storage', 'sampleId': 's1'}], extra: 'x'})
  366. with pytest.raises(ValidationError):
  367. MarketFreightCreateBody.model_validate({'transportMode': 'sf', 'freightAmount': 'NaN', 'items': [{'sampleSource': 'cell_storage', 'sampleId': 's1'}]})
  368. def test_fixed_routes_and_precise_permissions_are_registered():
  369. from app.api.market_mobile import router
  370. paths = [(route.path, route.methods) for route in router.routes]
  371. for kind in ('specialist', 'manager'):
  372. prefix = f'/mini/market/{kind}/freights'
  373. assert any(path == prefix and 'GET' in methods for path, methods in paths)
  374. assert any(path == prefix and 'POST' in methods for path, methods in paths)
  375. assert any(path == f'{prefix}/sample-options' and 'GET' in methods for path, methods in paths)
  376. source = (Path(__file__).parents[1] / 'app/api/market_mobile.py').read_text()
  377. assert "f'mini:market:{scope_kind}:freight'" in source
  378. def test_migration_has_mysql57_indexes_unique_active_link_and_duplicate_preflight():
  379. sql = (Path(__file__).parents[2] / 'docs/sql/migrations/2026-10-11-market-freight-indexes.sql').read_text()
  380. lowered = sql.lower()
  381. assert 'information_schema.statistics' in lowered
  382. assert 'generated always as' in lowered
  383. assert 'unique' in lowered and 'active' in lowered
  384. assert 'group by tenant_no, sample_source, sample_id' in lowered
  385. assert 'having count(*) > 1' in lowered
  386. assert 'create procedure' in lowered and 'signal sqlstate' in lowered
  387. assert 'character_maximum_length' in lowered
  388. assert 'data_type' in lowered and 'is_nullable' in lowered
  389. assert 'normalized_generation_expression' in lowered
  390. assert "uk_sample_freight_tenant_no" in lowered
  391. assert 'exact_freight_unique_indexes' in lowered
  392. def test_write_service_contains_locking_postlock_validation_and_integrity_retry():
  393. source = (Path(__file__).parents[1] / 'app/services/market_freight.py').read_text()
  394. assert source.count('with_for_update') >= 3
  395. assert 'IntegrityError' in source
  396. assert '_lock_batch_header' in source
  397. assert '_lock_sample_graph' in source
  398. assert '_lock_active_items' in source
  399. def test_multiple_party_rows_do_not_duplicate_visible_item_or_count():
  400. from app.services.market_freight import get_freight_detail
  401. db = _session()
  402. sample, parent = _sample(db, 'one')
  403. db.add(BizProtocolPartyCustomer(id='pc-one-duplicate', collection_protocol_id=parent.id, mother_name='重复客户行', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  404. db.commit()
  405. freight = _freight(db, 'one', [('cell_storage', sample, parent)])
  406. detail = get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())
  407. assert detail['visibleItemCount'] == 1
  408. assert len(detail['items']) == 1
  409. def test_deleted_batch_is_invisible_even_with_visible_active_item():
  410. from app.services.market_freight import list_freights
  411. db = _session()
  412. sample, parent = _sample(db, 'one')
  413. _freight(db, 'deleted-batch', [('cell_storage', sample, parent)], deleted=1)
  414. assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 0
  415. def test_context_user_mismatch_fails_closed():
  416. from app.services.market_freight import list_sample_options
  417. db = _session()
  418. _sample(db, 'one')
  419. with pytest.raises(PermissionError):
  420. list_sample_options(db, ctx=_ctx(), user_info=_user('u2'))
  421. def test_manager_can_link_sample_in_managed_region():
  422. from app.services.market_freight import link_samples
  423. db = _session()
  424. db.add(BizCollectionRegionStaff(id='staff-link', region_id='managed', user_id='m1', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0))
  425. db.commit()
  426. existing, ep = _sample(db, 'existing', sales='u9', manager='m1')
  427. new_sample, _ = _sample(db, 'new', sales='u9', manager='x', region='managed')
  428. freight = _freight(db, 'manager-link', [('cell_storage', existing, ep)])
  429. detail = link_samples(db, freight.id, ctx=_ctx('manager', 'm1'), user_info=_user('m1'), items=_items(('cell_storage', new_sample)))
  430. assert {item['sampleCode'] for item in detail['items']} == {'C-existing', 'C-new'}
  431. def test_batch_paging_is_stable_and_total_counts_data_scoped_batches():
  432. from app.services.market_freight import list_freights
  433. db = _session()
  434. first, fp = _sample(db, 'first')
  435. second, sp = _sample(db, 'second')
  436. hidden, hp = _sample(db, 'hidden-page', sales='u2')
  437. f1 = _freight(db, 'first', [('cell_storage', first, fp)])
  438. f2 = _freight(db, 'second', [('cell_storage', second, sp)])
  439. f3 = _freight(db, 'hidden-page', [('cell_storage', hidden, hp)])
  440. f1.create_time = f2.create_time
  441. f3.create_time = f2.create_time
  442. db.commit()
  443. page1, total1 = list_freights(db, ctx=_ctx(), user_info=_user(), page=1, page_size=1)
  444. page2, total2 = list_freights(db, ctx=_ctx(), user_info=_user(), page=2, page_size=1)
  445. page3, total3 = list_freights(db, ctx=_ctx(), user_info=_user(), page=3, page_size=1)
  446. assert total1 == total2 == total3 == 3
  447. assert [page1[0]['id'], page2[0]['id'], page3[0]['id']] == [
  448. 'f-second',
  449. 'f-hidden-page',
  450. 'f-first',
  451. ]
  452. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  453. def test_sample_options_choose_latest_party_once_before_count_and_paging(source):
  454. from app.services.market_freight import list_sample_options
  455. db = _session()
  456. base = datetime(2026, 10, 10, 8, 0)
  457. target, parent = _sample(db, f'duplicate-{source}', source=source, created=base)
  458. _sample(db, f'other-{source}', source=source, created=base + timedelta(hours=1))
  459. if source == 'cell_storage':
  460. db.add(BizProtocolPartyCustomer(
  461. id='zz-latest-cell-party', collection_protocol_id=parent.id,
  462. mother_name='最新细胞客户', tenant_no='tenant-a', create_dept_id='',
  463. create_time=base + timedelta(hours=2), del_flag=0,
  464. ))
  465. latest_name = '最新细胞客户'
  466. else:
  467. db.add(BizResearchDonationParty(
  468. id='zz-latest-donation-party', research_donation_id=parent.id,
  469. mother_name='最新科研客户', tenant_no='tenant-a', create_dept_id='',
  470. create_time=base + timedelta(hours=2), del_flag=0,
  471. ))
  472. latest_name = '最新科研客户'
  473. db.commit()
  474. page1, total1 = list_sample_options(
  475. db, ctx=_ctx(), user_info=_user(), page=1, page_size=1,
  476. sample_source=source,
  477. )
  478. page2, total2 = list_sample_options(
  479. db, ctx=_ctx(), user_info=_user(), page=2, page_size=1,
  480. sample_source=source,
  481. )
  482. rows = page1 + page2
  483. assert total1 == total2 == 2
  484. assert sum(row['sampleId'] == target.id for row in rows) == 1
  485. target_row = next(row for row in rows if row['sampleId'] == target.id)
  486. assert target_row['customerName'] == latest_name
  487. def test_mysql_lock_queries_are_current_reads_with_stable_global_order():
  488. from app.services.market_freight import (
  489. _active_item_lock_query,
  490. _collection_lock_query,
  491. _leader_lock_query,
  492. _sample_parent_lock_query,
  493. )
  494. db = _session()
  495. ctx = _ctx()
  496. sample_sql = str(
  497. _sample_parent_lock_query(
  498. db, 'cell_storage', ['z', 'a'], ctx
  499. ).statement.compile(dialect=mysql.dialect())
  500. ).lower()
  501. collection_sql = str(
  502. _collection_lock_query(
  503. db, 'cell_storage', ['p-z', 'p-a'], ctx
  504. ).statement.compile(dialect=mysql.dialect())
  505. ).lower()
  506. item_sql = str(
  507. _active_item_lock_query(
  508. db, ctx, freight_id='f1', keys=[('cell_storage', 'z')]
  509. ).statement.compile(dialect=mysql.dialect())
  510. ).lower()
  511. leader_sql = str(
  512. _leader_lock_query(db, _ctx('manager', 'm1')).statement.compile(
  513. dialect=mysql.dialect()
  514. )
  515. ).lower()
  516. for sql in (sample_sql, collection_sql, item_sql, leader_sql):
  517. assert ' for update' in sql
  518. assert ' order by ' in sql
  519. assert 'tenant_no' in sql and 'del_flag' in sql
  520. assert 'sales_user_id' in collection_sql
  521. assert 'biz_protocol_collection_info' in collection_sql
  522. assert 'biz_collection_region_staff' in leader_sql
  523. def test_all_write_paths_use_batch_graph_item_lock_order():
  524. source = (Path(__file__).parents[1] / 'app/services/market_freight.py').read_text()
  525. for start_name, end_name in (
  526. ('def update_freight', 'def link_samples'),
  527. ('def link_samples', 'def unlink_samples'),
  528. ('def unlink_samples', 'def delete_freight'),
  529. ('def delete_freight', None),
  530. ):
  531. block = source[source.index(start_name):]
  532. if end_name:
  533. block = block[:block.index(end_name)]
  534. assert block.index('_lock_batch_header') < block.index('_lock_sample_graph')
  535. assert block.index('_lock_sample_graph') < block.index('_lock_active_items')
  536. create_block = source[source.index('def create_freight'):source.index('def update_freight')]
  537. assert create_block.index('_lock_sample_graph') < create_block.index('_lock_active_items')
  538. def test_delete_freight_cascades_when_all_items_visible():
  539. from app.services.market_freight import delete_freight, list_freights, list_sample_options
  540. db = _session()
  541. own, op = _sample(db, 'del-own')
  542. freight = _freight(db, 'del-own', [('cell_storage', own, op)])
  543. result = delete_freight(db, freight.id, ctx=_ctx(), user_info=_user())
  544. assert result == {'id': freight.id, 'visibleItemCount': 0, 'disappeared': True}
  545. assert db.query(BizSampleFreight).filter_by(id=freight.id).one().del_flag == 1
  546. assert db.query(BizSampleFreightItem).filter_by(freight_id=freight.id, del_flag=0).count() == 0
  547. assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 0
  548. options, total = list_sample_options(db, ctx=_ctx(), user_info=_user(), page=1, page_size=20)
  549. assert total >= 1
  550. assert any(row['sampleId'] == own.id and not row.get('freightId') for row in options)
  551. def test_delete_freight_rejects_when_outside_scope_items_exist():
  552. from app.services.market_freight import delete_freight
  553. db = _session()
  554. own, op = _sample(db, 'del-vis')
  555. other, hp = _sample(db, 'del-hid', sales='u2')
  556. freight = _freight(db, 'del-mixed', [('cell_storage', own, op), ('cell_storage', other, hp)])
  557. with pytest.raises(PermissionError, match='全部'):
  558. delete_freight(db, freight.id, ctx=_ctx(), user_info=_user())
  559. assert db.query(BizSampleFreight).filter_by(id=freight.id).one().del_flag == 0
  560. assert db.query(BizSampleFreightItem).filter_by(freight_id=freight.id, del_flag=0).count() == 2
  561. def test_web_admin_delete_freight_cascades_items_and_allows_relink():
  562. from app.crud import sample_freight as freight_crud
  563. db = _session()
  564. own, op = _sample(db, 'web-del')
  565. freight = _freight(db, 'web-del', [('cell_storage', own, op)])
  566. empty = _freight(db, 'web-empty', [])
  567. result = freight_crud.delete_freight(
  568. db, freight.id, tenant_no='tenant-a', user_id='u1'
  569. )
  570. assert result == {'id': freight.id, 'deleted': True, 'linkedSampleCount': 0}
  571. assert db.query(BizSampleFreight).filter_by(id=freight.id).one().del_flag == 1
  572. assert db.query(BizSampleFreightItem).filter_by(freight_id=freight.id, del_flag=0).count() == 0
  573. empty_result = freight_crud.delete_freight(
  574. db, empty.id, tenant_no='tenant-a', user_id='u1'
  575. )
  576. assert empty_result['deleted'] is True
  577. assert db.query(BizSampleFreight).filter_by(id=empty.id).one().del_flag == 1
  578. created = freight_crud.create_freight(
  579. db,
  580. tenant_no='tenant-a',
  581. user_id='u1',
  582. transport_mode_raw='sf',
  583. freight_amount='8.00',
  584. items=[{'sampleSource': 'cell_storage', 'sampleId': own.id, 'protocolId': op.id}],
  585. )
  586. assert created['id']
  587. assert any(
  588. item.get('sampleId') == own.id for item in (created.get('items') or [])
  589. )
  590. def test_header_update_rejects_assignment_changed_after_prior_visible_read():
  591. from app.services.market_freight import get_freight_detail, update_freight
  592. db = _session()
  593. sample, parent = _sample(db, 'scope-race')
  594. freight = _freight(db, 'scope-race', [('cell_storage', sample, parent)])
  595. assert get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())['visibleItemCount'] == 1
  596. collection = db.query(BizProtocolCollectionInfo).filter_by(
  597. collection_protocol_id=parent.id
  598. ).one()
  599. db.query(BizProtocolCollectionInfo).filter_by(id=collection.id).update(
  600. {'sales_user_id': 'u2'}, synchronize_session=False
  601. )
  602. assert collection.sales_user_id == 'u1'
  603. with pytest.raises(HTTPException) as exc:
  604. update_freight(
  605. db, freight.id, ctx=_ctx(), user_info=_user(), freight_amount='55.00'
  606. )
  607. assert exc.value.status_code == 404
  608. assert db.query(BizSampleFreight).filter_by(id=freight.id).one().freight_amount == Decimal('12.30')
  609. def test_final_item_lock_rejects_participant_snapshot_change():
  610. from app.services.market_freight import _lock_active_items
  611. db = _session()
  612. first, fp = _sample(db, 'snapshot-first')
  613. second, sp = _sample(db, 'snapshot-second')
  614. freight = _freight(db, 'snapshot', [('cell_storage', first, fp)])
  615. expected = [('cell_storage', first.id)]
  616. db.add(BizSampleFreightItem(
  617. id='fi-snapshot-race', freight_id=freight.id,
  618. sample_source='cell_storage', sample_id=second.id,
  619. sample_code=second.sample_code, protocol_id=sp.id,
  620. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  621. ))
  622. db.commit()
  623. with pytest.raises(PermissionError, match='发生变化'):
  624. _lock_active_items(
  625. db, _ctx(), freight_id=freight.id,
  626. expected_batch_keys=expected,
  627. )
  628. def test_lock_graph_does_not_rely_on_exists_scope_for_current_field_values():
  629. source = (Path(__file__).parents[1] / 'app/services/market_freight.py').read_text()
  630. block = source[source.index('def _lock_sample_graph'):source.index('def _lock_active_items')]
  631. assert 'apply_cell_protocol_scope' not in block
  632. assert 'apply_donation_scope' not in block
  633. assert '_current_field_visible_parent_ids' in block
  634. assert '_current_data_scope_parent_ids' in block
  635. def test_manager_header_update_rejects_current_leader_revocation():
  636. from app.services.market_freight import get_freight_detail, update_freight
  637. db = _session()
  638. leader = BizCollectionRegionStaff(
  639. id='leader-race', region_id='managed-race', user_id='m1', is_leader=1,
  640. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  641. )
  642. db.add(leader)
  643. db.commit()
  644. sample, parent = _sample(
  645. db, 'leader-race', sales='u9', manager='other', region='managed-race'
  646. )
  647. freight = _freight(db, 'leader-race', [('cell_storage', sample, parent)])
  648. ctx = _ctx('manager', 'm1')
  649. assert get_freight_detail(db, freight.id, ctx=ctx, user_info=_user('m1'))['visibleItemCount'] == 1
  650. db.query(BizCollectionRegionStaff).filter_by(id=leader.id).update(
  651. {'is_leader': 0}, synchronize_session=False
  652. )
  653. assert leader.is_leader == 1
  654. with pytest.raises(HTTPException) as exc:
  655. update_freight(
  656. db, freight.id, ctx=ctx, user_info=_user('m1'), freight_amount='88.00'
  657. )
  658. assert exc.value.status_code == 404
  659. def test_header_update_uses_current_role_data_scope_not_prior_snapshot():
  660. from app.services.market_freight import get_freight_detail, update_freight
  661. db = _session()
  662. now = datetime(2026, 10, 11, 8, 0)
  663. db.add_all([
  664. SysUser(
  665. id='scope-user', dept_id='dept-current', user_no='scope-user',
  666. nick_name='范围用户', password='', tenant_no='tenant-a',
  667. create_dept_id='', create_time=now, update_time=now, del_flag=0,
  668. ),
  669. SysRole(
  670. id='scope-role', role_name='范围角色', data_scope='1', status='1',
  671. tenant_no='tenant-a', create_dept_id='', create_time=now,
  672. update_time=now, del_flag=0,
  673. ),
  674. SysUserRole(
  675. id='scope-user-role', user_id='scope-user', role_id='scope-role',
  676. tenant_no='tenant-a', create_dept_id='', create_time=now,
  677. update_time=now, del_flag=0,
  678. ),
  679. ])
  680. db.commit()
  681. sample, parent = _sample(db, 'data-scope', sales='scope-user')
  682. parent.create_by = 'another-user'
  683. parent.create_dept_id = 'another-dept'
  684. db.commit()
  685. freight = _freight(db, 'data-scope', [('cell_storage', sample, parent)])
  686. ctx = _ctx('specialist', 'scope-user')
  687. user = {'user_id': 'scope-user', 'tenant_no': 'tenant-a', 'role': 'admin'}
  688. assert get_freight_detail(db, freight.id, ctx=ctx, user_info=user)['visibleItemCount'] == 1
  689. cached_role = db.query(SysRole).filter_by(id='scope-role').one()
  690. db.query(SysRole).filter_by(id='scope-role').update(
  691. {'data_scope': '4'}, synchronize_session=False
  692. )
  693. assert cached_role.data_scope == '1'
  694. with pytest.raises(HTTPException) as exc:
  695. update_freight(
  696. db, freight.id, ctx=ctx, user_info=user, freight_amount='77.00'
  697. )
  698. assert exc.value.status_code == 404
  699. def test_current_data_scope_preserves_post_roles_custom_scope_and_union():
  700. from app.services.market_freight import _current_data_scope_parent_ids
  701. db = _session()
  702. now = datetime(2026, 10, 11, 8, 0)
  703. db.add_all([
  704. SysUser(
  705. id='union-user', dept_id='union-home', user_no='union-user',
  706. nick_name='并集用户', password='', tenant_no='tenant-a',
  707. create_dept_id='', create_time=now, update_time=now, del_flag=0,
  708. ),
  709. SysRole(
  710. id='custom-role', role_name='自定义范围', data_scope='5', status='1',
  711. tenant_no='tenant-a', create_dept_id='', create_time=now,
  712. update_time=now, del_flag=0,
  713. ),
  714. SysRole(
  715. id='self-role', role_name='本人范围', data_scope='4', status='1',
  716. tenant_no='tenant-a', create_dept_id='', create_time=now,
  717. update_time=now, del_flag=0,
  718. ),
  719. SysUserRole(
  720. id='union-direct', user_id='union-user', role_id='custom-role',
  721. tenant_no='tenant-a', create_dept_id='', create_time=now,
  722. update_time=now, del_flag=0,
  723. ),
  724. SysRoleDataScopeDetail(
  725. id='union-custom-detail', role_id='custom-role', scope_type='dept',
  726. scope_value='custom-dept', tenant_no='tenant-a', create_dept_id='',
  727. create_time=now, update_time=now, del_flag=0,
  728. ),
  729. SysPost(
  730. id='union-post', post_name='继承职位', post_code='union-post',
  731. status='1', tenant_no='tenant-a', create_dept_id='',
  732. create_time=now, update_time=now, del_flag=0,
  733. ),
  734. SysUserPost(
  735. id='union-user-post', user_id='union-user', post_id='union-post',
  736. tenant_no='tenant-a', create_dept_id='', create_time=now,
  737. update_time=now, del_flag=0,
  738. ),
  739. SysPostRole(
  740. id='union-post-role', post_id='union-post', role_id='self-role',
  741. tenant_no='tenant-a', create_dept_id='', create_time=now,
  742. update_time=now, del_flag=0,
  743. ),
  744. ])
  745. db.commit()
  746. _, custom_parent = _sample(db, 'union-custom')
  747. _, self_parent = _sample(db, 'union-self')
  748. _, denied_parent = _sample(db, 'union-denied')
  749. custom_parent.create_dept_id = 'custom-dept'
  750. self_parent.create_by = 'union-user'
  751. denied_parent.create_dept_id = 'other-dept'
  752. denied_parent.create_by = 'other-user'
  753. db.commit()
  754. visible = _current_data_scope_parent_ids(
  755. db,
  756. [custom_parent, self_parent, denied_parent],
  757. _ctx('specialist', 'union-user'),
  758. {'user_id': 'union-user', 'tenant_no': 'tenant-a', 'role': 'admin'},
  759. )
  760. assert visible == {custom_parent.id, self_parent.id}
  761. def test_current_data_scope_preserves_department_descendant_scope():
  762. from app.services.market_freight import _current_data_scope_parent_ids
  763. db = _session()
  764. now = datetime(2026, 10, 11, 8, 0)
  765. db.add_all([
  766. SysUser(
  767. id='dept-user', dept_id='dept-root', user_no='dept-user',
  768. nick_name='部门用户', password='', tenant_no='tenant-a',
  769. create_dept_id='', create_time=now, update_time=now, del_flag=0,
  770. ),
  771. SysRole(
  772. id='dept-role', role_name='部门以下', data_scope='3', status='1',
  773. tenant_no='tenant-a', create_dept_id='', create_time=now,
  774. update_time=now, del_flag=0,
  775. ),
  776. SysUserRole(
  777. id='dept-user-role', user_id='dept-user', role_id='dept-role',
  778. tenant_no='tenant-a', create_dept_id='', create_time=now,
  779. update_time=now, del_flag=0,
  780. ),
  781. SysDept(
  782. id='dept-root', parent_id='0', dept_name='根部门', status='1',
  783. tenant_no='tenant-a', create_dept_id='', create_time=now,
  784. update_time=now, del_flag=0,
  785. ),
  786. SysDept(
  787. id='dept-child', parent_id='dept-root', dept_name='子部门', status='1',
  788. tenant_no='tenant-a', create_dept_id='', create_time=now,
  789. update_time=now, del_flag=0,
  790. ),
  791. ])
  792. db.commit()
  793. _, child_parent = _sample(db, 'dept-child')
  794. _, denied_parent = _sample(db, 'dept-denied')
  795. child_parent.create_dept_id = 'dept-child'
  796. denied_parent.create_dept_id = 'other-dept'
  797. db.commit()
  798. visible = _current_data_scope_parent_ids(
  799. db,
  800. [child_parent, denied_parent],
  801. _ctx('specialist', 'dept-user'),
  802. {'user_id': 'dept-user', 'tenant_no': 'tenant-a', 'role': 'admin'},
  803. )
  804. assert visible == {child_parent.id}
  805. def test_locked_data_scope_ignores_payload_superadmin_across_tenants():
  806. from app.services.market_freight import (
  807. _current_data_scope_parent_ids,
  808. get_freight_detail,
  809. )
  810. db = _session()
  811. now = datetime(2026, 10, 11, 8, 0)
  812. db.add_all([
  813. SysUser(
  814. id='cross-user', dept_id='cross-dept', user_no='cross-user',
  815. nick_name='跨租户用户', password='', tenant_no='tenant-a',
  816. create_dept_id='', create_time=now, update_time=now, del_flag=0,
  817. ),
  818. SysRole(
  819. id='default-super-role', role_name='超级管理员', data_scope='1',
  820. status='1', tenant_no='zkxw', create_dept_id='', create_time=now,
  821. update_time=now, del_flag=0,
  822. ),
  823. SysUserRole(
  824. id='default-super-link', user_id='cross-user',
  825. role_id='default-super-role', tenant_no='zkxw', create_dept_id='',
  826. create_time=now, update_time=now, del_flag=0,
  827. ),
  828. SysRole(
  829. id='business-self-role', role_name='业务专员', data_scope='4',
  830. status='1', tenant_no='tenant-a', create_dept_id='', create_time=now,
  831. update_time=now, del_flag=0,
  832. ),
  833. SysUserRole(
  834. id='business-self-link', user_id='cross-user',
  835. role_id='business-self-role', tenant_no='tenant-a',
  836. create_dept_id='', create_time=now, update_time=now, del_flag=0,
  837. ),
  838. ])
  839. db.commit()
  840. sample, parent = _sample(
  841. db, 'cross-tenant-scope', sales='cross-user'
  842. )
  843. parent.create_by = 'another-user'
  844. parent.create_dept_id = 'another-dept'
  845. db.commit()
  846. ctx = _ctx('specialist', 'cross-user')
  847. forged_default_tenant_payload = {
  848. 'user_id': 'cross-user', 'tenant_no': 'tenant-a', 'role': 'super_admin'
  849. }
  850. freight = _freight(
  851. db, 'cross-tenant-scope', [('cell_storage', sample, parent)]
  852. )
  853. assert _current_data_scope_parent_ids(
  854. db, [parent], ctx, forged_default_tenant_payload
  855. ) == set()
  856. with pytest.raises(HTTPException) as exc:
  857. get_freight_detail(
  858. db, freight.id, ctx=ctx, user_info=forged_default_tenant_payload
  859. )
  860. assert exc.value.status_code == 404
  861. db.add_all([
  862. SysRole(
  863. id='business-super-role', role_name='超级管理员', data_scope='4',
  864. status='1', tenant_no='tenant-a', create_dept_id='', create_time=now,
  865. update_time=now, del_flag=0,
  866. ),
  867. SysPost(
  868. id='business-super-post', post_name='业务租户超级管理职位',
  869. post_code='business-super-post', status='1', tenant_no='tenant-a',
  870. create_dept_id='', create_time=now, update_time=now, del_flag=0,
  871. ),
  872. SysUserPost(
  873. id='business-super-user-post', user_id='cross-user',
  874. post_id='business-super-post', tenant_no='tenant-a',
  875. create_dept_id='', create_time=now, update_time=now, del_flag=0,
  876. ),
  877. SysPostRole(
  878. id='business-super-post-role', post_id='business-super-post',
  879. role_id='business-super-role', tenant_no='tenant-a',
  880. create_dept_id='', create_time=now, update_time=now, del_flag=0,
  881. ),
  882. ])
  883. db.commit()
  884. assert _current_data_scope_parent_ids(
  885. db, [parent], ctx, forged_default_tenant_payload
  886. ) == {parent.id}
  887. assert get_freight_detail(
  888. db, freight.id, ctx=ctx, user_info=forged_default_tenant_payload
  889. )['visibleItemCount'] == 1