Let customer-service quick register write storage, certificate, and insurance separately without extra menu or data-scope checks. List freight by document data scope like the web admin, allow protocol staff outside the collection region, and pad OA datetimes to seconds so PC date pickers can show historical minute values. Co-authored-by: Cursor <cursoragent@cursor.com>
1009 lines
46 KiB
Python
1009 lines
46 KiB
Python
"""市场移动端运费批次:作用域、脱敏与原子写规则。"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.dialects import mysql
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from app.models.base import Base
|
|
from app.models.models import (
|
|
BizCellSampleType, BizCollectionProtocol, BizCollectionRegionStaff, BizProtocolCollectionInfo,
|
|
BizProtocolPartyCustomer, BizProtocolSample, BizSampleFreight,
|
|
BizSampleFreightItem, SysDictData, SysDictType, SysPost, SysPostRole,
|
|
SysDept, SysRole, SysRoleDataScopeDetail, SysUser, SysUserPost, SysUserRole,
|
|
)
|
|
from app.models.research_donation import (
|
|
BizResearchDonation, BizResearchDonationCollection,
|
|
BizResearchDonationParty, BizResearchDonationSample,
|
|
)
|
|
from app.services.market_field_scope import MarketFieldContext
|
|
|
|
TABLES = [
|
|
BizCollectionProtocol.__table__, BizProtocolCollectionInfo.__table__,
|
|
BizProtocolPartyCustomer.__table__, BizProtocolSample.__table__,
|
|
BizResearchDonation.__table__, BizResearchDonationCollection.__table__,
|
|
BizResearchDonationParty.__table__, BizResearchDonationSample.__table__,
|
|
BizSampleFreight.__table__, BizSampleFreightItem.__table__,
|
|
BizCellSampleType.__table__,
|
|
BizCollectionRegionStaff.__table__, SysDictType.__table__,
|
|
SysDictData.__table__, SysUser.__table__, SysRole.__table__,
|
|
SysRoleDataScopeDetail.__table__, SysUserRole.__table__, SysPost.__table__,
|
|
SysPostRole.__table__, SysUserPost.__table__, SysDept.__table__,
|
|
]
|
|
|
|
|
|
def _session():
|
|
engine = create_engine('sqlite:///:memory:')
|
|
Base.metadata.create_all(engine, tables=TABLES)
|
|
db = sessionmaker(bind=engine)()
|
|
now = datetime(2026, 10, 11, 8, 0)
|
|
db.add_all([
|
|
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),
|
|
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),
|
|
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),
|
|
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),
|
|
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),
|
|
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),
|
|
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),
|
|
])
|
|
db.commit()
|
|
return db
|
|
|
|
|
|
def _ctx(kind='specialist', user_id='u1'):
|
|
return MarketFieldContext('tenant-a', user_id, kind)
|
|
|
|
|
|
def _user(user_id='u1'):
|
|
return {'user_id': user_id, 'tenant_no': 'tenant-a', 'role': 'super_admin'}
|
|
|
|
|
|
def _sample(db, key, *, source='cell_storage', sales='u1', manager='m1', region='r1', tenant='tenant-a', deleted=0, created=None):
|
|
created = created or datetime(2026, 10, 10, 8, 0)
|
|
if source == 'cell_storage':
|
|
parent = BizCollectionProtocol(id=f'cp-{key}', protocol_no=f'CP-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
|
|
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)
|
|
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)
|
|
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)
|
|
else:
|
|
parent = BizResearchDonation(id=f'rd-{key}', donation_no=f'RD-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
|
|
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)
|
|
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)
|
|
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)
|
|
db.add_all([parent, collection, party, sample])
|
|
db.commit()
|
|
return sample, parent
|
|
|
|
|
|
def _freight(db, key, samples, *, tenant='tenant-a', amount='12.30', deleted=0, create_by='u1'):
|
|
now = datetime(2026, 10, 11, 8, 0)
|
|
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)
|
|
db.add(freight)
|
|
for pos, (source, sample, parent) in enumerate(samples):
|
|
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))
|
|
db.commit()
|
|
return freight
|
|
|
|
|
|
def _items(*pairs):
|
|
return [{'sampleSource': source, 'sampleId': sample.id} for source, sample in pairs]
|
|
|
|
|
|
def test_specialist_sees_shared_batch_but_only_own_items_and_count():
|
|
from app.services.market_freight import get_freight_detail, list_freights
|
|
db = _session()
|
|
own, own_parent = _sample(db, 'own')
|
|
other, other_parent = _sample(db, 'other', sales='u2')
|
|
freight = _freight(db, 'mixed', [('cell_storage', own, own_parent), ('cell_storage', other, other_parent)])
|
|
rows, total = list_freights(db, ctx=_ctx(), user_info=_user(), include_items=True)
|
|
detail = get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())
|
|
assert total == 1 and rows[0]['visibleItemCount'] == 1
|
|
assert [item['sampleCode'] for item in rows[0]['items']] == ['C-own']
|
|
assert [item['sampleCode'] for item in detail['items']] == ['C-own']
|
|
assert rows[0]['createBy'] == detail['createBy'] == 'u1'
|
|
assert rows[0]['createByName'] == detail['createByName'] == '专员'
|
|
assert 'linkedSampleCount' not in rows[0] and 'linkedSampleCount' not in detail
|
|
assert 'hasHiddenItems' not in detail
|
|
|
|
|
|
def test_freight_creator_name_uses_historical_nickname_without_id_fallback():
|
|
from app.services.market_freight import get_freight_detail, list_freights
|
|
|
|
db = _session()
|
|
now = datetime(2026, 10, 11, 8, 0)
|
|
db.add_all(
|
|
[
|
|
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),
|
|
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),
|
|
]
|
|
)
|
|
first_sample, first_parent = _sample(db, 'creator-history')
|
|
second_sample, second_parent = _sample(db, 'creator-blank')
|
|
historical = _freight(
|
|
db,
|
|
'creator-history',
|
|
[('cell_storage', first_sample, first_parent)],
|
|
create_by='deleted-creator',
|
|
)
|
|
blank = _freight(
|
|
db,
|
|
'creator-blank',
|
|
[('cell_storage', second_sample, second_parent)],
|
|
create_by='blank-creator',
|
|
)
|
|
|
|
items, total = list_freights(db, ctx=_ctx(), user_info=_user())
|
|
by_id = {item['id']: item for item in items}
|
|
historical_detail = get_freight_detail(
|
|
db, historical.id, ctx=_ctx(), user_info=_user()
|
|
)
|
|
blank_detail = get_freight_detail(
|
|
db, blank.id, ctx=_ctx(), user_info=_user()
|
|
)
|
|
|
|
assert total == 2
|
|
assert by_id[historical.id]['createByName'] == '历史创建人'
|
|
assert historical_detail['createByName'] == '历史创建人'
|
|
assert by_id[blank.id]['createByName'] == ''
|
|
assert blank_detail['createByName'] == ''
|
|
|
|
|
|
def test_batch_without_visible_item_is_listed_and_linkable_like_web():
|
|
"""列表与 Web 一致:按运费单数据权限展示,不再要求现场可见明细。"""
|
|
from app.services.market_freight import get_freight_detail, link_samples, list_freights
|
|
|
|
db = _session()
|
|
other, parent = _sample(db, 'other', sales='u2')
|
|
own, own_parent = _sample(db, 'own')
|
|
freight = _freight(db, 'hidden', [('cell_storage', other, parent)])
|
|
rows, total = list_freights(db, ctx=_ctx(), user_info=_user(), include_items=True)
|
|
detail = get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())
|
|
linked = link_samples(
|
|
db,
|
|
freight.id,
|
|
ctx=_ctx(),
|
|
user_info=_user(),
|
|
items=_items(('cell_storage', own)),
|
|
)
|
|
|
|
assert total == 1
|
|
assert rows[0]['id'] == freight.id
|
|
assert rows[0]['items'] == []
|
|
assert detail['id'] == freight.id
|
|
assert detail['items'] == []
|
|
assert detail['visibleItemCount'] == 0
|
|
assert linked['visibleItemCount'] == 1
|
|
assert [item['sampleCode'] for item in linked['items']] == ['C-own']
|
|
|
|
|
|
def test_keyword_and_source_filters_match_any_batch_items_like_web():
|
|
from app.services.market_freight import list_freights
|
|
db = _session()
|
|
own, op = _sample(db, 'own')
|
|
secret, sp = _sample(db, 'SECRET', source='research_donation', sales='u2')
|
|
_freight(db, 'mixed', [('cell_storage', own, op), ('research_donation', secret, sp)])
|
|
assert list_freights(db, ctx=_ctx(), user_info=_user(), keyword='SECRET')[1] == 1
|
|
assert list_freights(db, ctx=_ctx(), user_info=_user(), sample_source='research_donation')[1] == 1
|
|
assert list_freights(db, ctx=_ctx(), user_info=_user(), sample_source='cell_storage')[1] == 1
|
|
|
|
|
|
def test_freight_list_filters_create_time_range():
|
|
from app.services.market_freight import list_freights
|
|
|
|
db = _session()
|
|
first, first_parent = _sample(db, 'created-in-range')
|
|
second, second_parent = _sample(db, 'created-outside')
|
|
in_range = _freight(
|
|
db,
|
|
'created-in-range',
|
|
[('cell_storage', first, first_parent)],
|
|
)
|
|
outside = _freight(
|
|
db,
|
|
'created-outside',
|
|
[('cell_storage', second, second_parent)],
|
|
)
|
|
in_range.create_time = datetime(2026, 10, 11, 8, 0)
|
|
outside.create_time = datetime(2026, 9, 1, 8, 0)
|
|
db.commit()
|
|
|
|
items, total = list_freights(
|
|
db,
|
|
ctx=_ctx(),
|
|
user_info=_user(),
|
|
create_time_start='2026-10-01',
|
|
create_time_end='2026-10-13',
|
|
)
|
|
|
|
assert total == 1
|
|
assert [item['id'] for item in items] == [in_range.id]
|
|
|
|
|
|
def test_manager_scope_is_region_union_document_manager_for_both_sources():
|
|
from app.services.market_freight import get_freight_detail
|
|
db = _session()
|
|
db.add(BizCollectionRegionStaff(id='staff', region_id='managed', user_id='m1', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0))
|
|
db.commit()
|
|
region_sample, rp = _sample(db, 'region', sales='u9', manager='x', region='managed')
|
|
assigned, ap = _sample(db, 'assigned', source='research_donation', sales='u9', manager='m1', region='other')
|
|
hidden, hp = _sample(db, 'hidden', sales='m1', manager='x', region='other')
|
|
freight = _freight(db, 'manager', [('cell_storage', region_sample, rp), ('research_donation', assigned, ap), ('cell_storage', hidden, hp)])
|
|
detail = get_freight_detail(db, freight.id, ctx=_ctx('manager', 'm1'), user_info=_user('m1'))
|
|
assert {item['sampleCode'] for item in detail['items']} == {'C-region', 'R-assigned'}
|
|
assert detail['visibleItemCount'] == 2
|
|
assert all('sampleTypeName' in item and 'motherName' in item and 'newbornName' in item for item in detail['items'])
|
|
|
|
|
|
def test_dual_role_list_uses_data_scope_not_field_scope():
|
|
"""列表与 Web 一致仅走数据权限;专员/经理路由不再按现场范围裁剪批次。"""
|
|
from app.services.market_freight import list_freights
|
|
db = _session()
|
|
specialist, sp = _sample(db, 'specialist', sales='m1', manager='x', region='other')
|
|
manager, mp = _sample(db, 'manager', sales='u9', manager='m1', region='other')
|
|
_freight(db, 'specialist', [('cell_storage', specialist, sp)])
|
|
_freight(db, 'manager', [('cell_storage', manager, mp)])
|
|
specialist_rows, _ = list_freights(db, ctx=_ctx('specialist', 'm1'), user_info=_user('m1'))
|
|
manager_rows, _ = list_freights(db, ctx=_ctx('manager', 'm1'), user_info=_user('m1'))
|
|
assert {row['freightNo'] for row in specialist_rows} == {'YF-specialist', 'YF-manager'}
|
|
assert {row['freightNo'] for row in manager_rows} == {'YF-specialist', 'YF-manager'}
|
|
|
|
|
|
def test_cross_tenant_freight_is_invisible_while_orphan_item_batch_remains_listed():
|
|
from app.services.market_freight import list_freights
|
|
db = _session()
|
|
cross, cp = _sample(db, 'cross', tenant='tenant-b')
|
|
deleted, dp = _sample(db, 'deleted', deleted=1)
|
|
_freight(db, 'cross', [('cell_storage', cross, cp)], tenant='tenant-b')
|
|
orphan = _freight(db, 'deleted', [('cell_storage', deleted, dp)])
|
|
rows, total = list_freights(db, ctx=_ctx(), user_info=_user())
|
|
assert total == 1
|
|
assert rows[0]['id'] == orphan.id
|
|
|
|
|
|
def test_sample_options_are_scoped_sql_paginated_stable_and_derive_link_state():
|
|
from app.services.market_freight import list_sample_options
|
|
db = _session()
|
|
base = datetime(2026, 10, 10, 8, 0)
|
|
first, fp = _sample(db, 'first', created=base)
|
|
_sample(db, 'second', source='research_donation', created=base + timedelta(hours=1))
|
|
_sample(db, 'third', created=base + timedelta(hours=2))
|
|
_sample(db, 'hidden', sales='u2', created=base + timedelta(hours=3))
|
|
_freight(db, 'linked', [('cell_storage', first, fp)])
|
|
page1, total = list_sample_options(db, ctx=_ctx(), user_info=_user(), page=1, page_size=2)
|
|
page2, total2 = list_sample_options(db, ctx=_ctx(), user_info=_user(), page=2, page_size=2)
|
|
assert total == total2 == 3
|
|
assert [x['sampleCode'] for x in page1] == ['C-third', 'R-second']
|
|
assert [x['sampleCode'] for x in page2] == ['C-first']
|
|
assert page2[0]['freightId'] == 'f-linked'
|
|
assert page2[0]['freightNo'] == 'YF-linked'
|
|
assert set(('protocolNo', 'customerName', 'newbornName', 'sampleTypeName')).issubset(page2[0])
|
|
|
|
|
|
def test_create_derives_snapshots_and_decimal_string_and_requires_visible_item():
|
|
from app.services.market_freight import create_freight
|
|
db = _session()
|
|
own, parent = _sample(db, 'own')
|
|
result = create_freight(db, ctx=_ctx(), user_info=_user(), transport_mode='sf', freight_amount=Decimal('12.30'), items=_items(('cell_storage', own)))
|
|
item = db.query(BizSampleFreightItem).filter_by(freight_id=result['id']).one()
|
|
assert result['freightAmount'] == '12.30'
|
|
assert item.sample_code == 'C-own' and item.protocol_id == parent.id
|
|
with pytest.raises(ValueError, match='至少'):
|
|
create_freight(db, ctx=_ctx(), user_info=_user(), transport_mode='sf', freight_amount='1.00', items=[])
|
|
|
|
|
|
def test_create_prevalidates_every_item_and_rolls_back_without_partial_batch():
|
|
from app.services.market_freight import create_freight
|
|
db = _session()
|
|
own, _ = _sample(db, 'own')
|
|
other, _ = _sample(db, 'other', sales='u2')
|
|
before = db.query(BizSampleFreight).count()
|
|
with pytest.raises(HTTPException):
|
|
create_freight(db, ctx=_ctx(), user_info=_user(), transport_mode='sf', freight_amount='1.00', items=_items(('cell_storage', own), ('cell_storage', other)))
|
|
assert db.query(BizSampleFreight).count() == before
|
|
|
|
|
|
def test_link_rejects_hidden_and_existing_other_batch_without_silent_move():
|
|
from app.services.market_freight import link_samples
|
|
db = _session()
|
|
own, op = _sample(db, 'own')
|
|
other, _ = _sample(db, 'other', sales='u2')
|
|
linked, lp = _sample(db, 'linked')
|
|
target = _freight(db, 'target', [('cell_storage', own, op)])
|
|
source = _freight(db, 'source', [('cell_storage', linked, lp)])
|
|
for sample in (other, linked):
|
|
with pytest.raises((HTTPException, ValueError)):
|
|
link_samples(db, target.id, ctx=_ctx(), user_info=_user(), items=_items(('cell_storage', sample)))
|
|
assert db.query(BizSampleFreightItem).filter_by(sample_id=linked.id, del_flag=0).one().freight_id == source.id
|
|
|
|
|
|
def test_unlink_prevalidates_all_items_and_rejects_hidden_or_nonexistent():
|
|
from app.services.market_freight import unlink_samples
|
|
db = _session()
|
|
own, op = _sample(db, 'own')
|
|
other, hp = _sample(db, 'hidden', sales='u2')
|
|
freight = _freight(db, 'mixed', [('cell_storage', own, op), ('cell_storage', other, hp)])
|
|
missing, _ = _sample(db, 'missing')
|
|
for sample in (other, missing):
|
|
with pytest.raises((HTTPException, ValueError)):
|
|
unlink_samples(db, freight.id, ctx=_ctx(), user_info=_user(), items=_items(('cell_storage', sample)))
|
|
assert db.query(BizSampleFreightItem).filter_by(freight_id=freight.id, del_flag=0).count() == 2
|
|
|
|
|
|
def test_unlink_last_visible_item_disappears_but_batch_remains_listed():
|
|
from app.services.market_freight import list_freights, unlink_samples
|
|
db = _session()
|
|
own, op = _sample(db, 'own')
|
|
other, hp = _sample(db, 'hidden', sales='u2')
|
|
freight = _freight(db, 'mixed', [('cell_storage', own, op), ('cell_storage', other, hp)])
|
|
result = unlink_samples(db, freight.id, ctx=_ctx(), user_info=_user(), items=_items(('cell_storage', own)))
|
|
assert result == {'id': freight.id, 'visibleItemCount': 0, 'disappeared': True}
|
|
assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 1
|
|
assert db.query(BizSampleFreight).filter_by(id=freight.id).one().del_flag == 0
|
|
|
|
|
|
@pytest.mark.parametrize('kind,user_id', [('specialist', 'u1'), ('manager', 'm1')])
|
|
def test_shared_outside_scope_header_is_immutable_for_both_roles(kind, user_id):
|
|
from app.services.market_freight import update_freight
|
|
db = _session()
|
|
visible, vp = _sample(db, 'visible', sales=user_id if kind == 'specialist' else 'u9', manager=user_id if kind == 'manager' else 'x')
|
|
hidden, hp = _sample(db, 'hidden', sales='other', manager='other')
|
|
freight = _freight(db, 'mixed', [('cell_storage', visible, vp), ('cell_storage', hidden, hp)])
|
|
with pytest.raises(PermissionError, match='全部'):
|
|
update_freight(db, freight.id, ctx=_ctx(kind, user_id), user_info=_user(user_id), freight_amount='99.00')
|
|
assert db.query(BizSampleFreight).filter_by(id=freight.id).one().freight_amount == Decimal('12.30')
|
|
|
|
|
|
def test_header_update_allowed_when_all_active_items_are_visible():
|
|
from app.services.market_freight import update_freight
|
|
db = _session()
|
|
own, op = _sample(db, 'own')
|
|
freight = _freight(db, 'own', [('cell_storage', own, op)])
|
|
result = update_freight(db, freight.id, ctx=_ctx(), user_info=_user(), freight_amount='99.10')
|
|
assert result['freightAmount'] == '99.10'
|
|
|
|
|
|
@pytest.mark.parametrize('kind,user_id', [('specialist', 'u1'), ('manager', 'm1')])
|
|
@pytest.mark.parametrize('mixed', [False, True])
|
|
def test_detail_edit_action_requires_every_active_item_visible_without_leaking_totals(
|
|
kind, user_id, mixed
|
|
):
|
|
from app.services.market_freight import get_freight_detail
|
|
from app.services.market_support import decorate_allowed_actions
|
|
|
|
db = _session()
|
|
visible, visible_parent = _sample(
|
|
db,
|
|
f'action-visible-{kind}-{mixed}',
|
|
sales=user_id if kind == 'specialist' else 'other-sales',
|
|
manager=user_id if kind == 'manager' else 'other-manager',
|
|
)
|
|
rows = [('cell_storage', visible, visible_parent)]
|
|
if mixed:
|
|
hidden, hidden_parent = _sample(
|
|
db,
|
|
f'action-hidden-{kind}',
|
|
sales='hidden-sales',
|
|
manager='hidden-manager',
|
|
)
|
|
rows.append(('cell_storage', hidden, hidden_parent))
|
|
freight = _freight(db, f'action-{kind}-{mixed}', rows)
|
|
|
|
detail = get_freight_detail(
|
|
db,
|
|
freight.id,
|
|
ctx=_ctx(kind, user_id),
|
|
user_info=_user(user_id),
|
|
)
|
|
rendered = decorate_allowed_actions(
|
|
'freight', detail, scope_kind=kind, detail=True
|
|
)
|
|
|
|
expected = ['link', 'unlink'] if mixed else ['edit', 'delete', 'link', 'unlink']
|
|
assert detail['allowedActions'] == expected
|
|
assert rendered['allowedActions'] == expected
|
|
for hidden_key in (
|
|
'allItemsVisible',
|
|
'hiddenItemCount',
|
|
'totalItemCount',
|
|
'activeItemCount',
|
|
):
|
|
assert hidden_key not in detail
|
|
|
|
|
|
def test_dto_forbids_scope_snapshots_status_and_non_decimal_amounts():
|
|
from app.schemas.market_mobile import MarketFreightCreateBody, MarketFreightItemsBody
|
|
with pytest.raises(ValidationError):
|
|
MarketFreightItemsBody.model_validate({'items': [{'sampleSource': 'cell_storage', 'sampleId': 's1', 'sampleCode': 'forged'}]})
|
|
for extra in ('tenantNo', 'scope', 'status'):
|
|
with pytest.raises(ValidationError):
|
|
MarketFreightCreateBody.model_validate({'transportMode': 'sf', 'freightAmount': '1.00', 'items': [{'sampleSource': 'cell_storage', 'sampleId': 's1'}], extra: 'x'})
|
|
with pytest.raises(ValidationError):
|
|
MarketFreightCreateBody.model_validate({'transportMode': 'sf', 'freightAmount': 'NaN', 'items': [{'sampleSource': 'cell_storage', 'sampleId': 's1'}]})
|
|
|
|
|
|
def test_fixed_routes_and_precise_permissions_are_registered():
|
|
from app.api.market_mobile import router
|
|
paths = [(route.path, route.methods) for route in router.routes]
|
|
for kind in ('specialist', 'manager'):
|
|
prefix = f'/mini/market/{kind}/freights'
|
|
assert any(path == prefix and 'GET' in methods for path, methods in paths)
|
|
assert any(path == prefix and 'POST' in methods for path, methods in paths)
|
|
assert any(path == f'{prefix}/sample-options' and 'GET' in methods for path, methods in paths)
|
|
source = (Path(__file__).parents[1] / 'app/api/market_mobile.py').read_text()
|
|
assert "f'mini:market:{scope_kind}:freight'" in source
|
|
|
|
|
|
def test_migration_has_mysql57_indexes_unique_active_link_and_duplicate_preflight():
|
|
sql = (Path(__file__).parents[2] / 'docs/sql/migrations/2026-10-11-market-freight-indexes.sql').read_text()
|
|
lowered = sql.lower()
|
|
assert 'information_schema.statistics' in lowered
|
|
assert 'generated always as' in lowered
|
|
assert 'unique' in lowered and 'active' in lowered
|
|
assert 'group by tenant_no, sample_source, sample_id' in lowered
|
|
assert 'having count(*) > 1' in lowered
|
|
assert 'create procedure' in lowered and 'signal sqlstate' in lowered
|
|
assert 'character_maximum_length' in lowered
|
|
assert 'data_type' in lowered and 'is_nullable' in lowered
|
|
assert 'normalized_generation_expression' in lowered
|
|
assert "uk_sample_freight_tenant_no" in lowered
|
|
assert 'exact_freight_unique_indexes' in lowered
|
|
|
|
|
|
def test_write_service_contains_locking_postlock_validation_and_integrity_retry():
|
|
source = (Path(__file__).parents[1] / 'app/services/market_freight.py').read_text()
|
|
assert source.count('with_for_update') >= 3
|
|
assert 'IntegrityError' in source
|
|
assert '_lock_batch_header' in source
|
|
assert '_lock_sample_graph' in source
|
|
assert '_lock_active_items' in source
|
|
|
|
|
|
def test_multiple_party_rows_do_not_duplicate_visible_item_or_count():
|
|
from app.services.market_freight import get_freight_detail
|
|
db = _session()
|
|
sample, parent = _sample(db, 'one')
|
|
db.add(BizProtocolPartyCustomer(id='pc-one-duplicate', collection_protocol_id=parent.id, mother_name='重复客户行', tenant_no='tenant-a', create_dept_id='', del_flag=0))
|
|
db.commit()
|
|
freight = _freight(db, 'one', [('cell_storage', sample, parent)])
|
|
detail = get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())
|
|
assert detail['visibleItemCount'] == 1
|
|
assert len(detail['items']) == 1
|
|
|
|
|
|
def test_deleted_batch_is_invisible_even_with_visible_active_item():
|
|
from app.services.market_freight import list_freights
|
|
db = _session()
|
|
sample, parent = _sample(db, 'one')
|
|
_freight(db, 'deleted-batch', [('cell_storage', sample, parent)], deleted=1)
|
|
assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 0
|
|
|
|
|
|
def test_context_user_mismatch_fails_closed():
|
|
from app.services.market_freight import list_sample_options
|
|
db = _session()
|
|
_sample(db, 'one')
|
|
with pytest.raises(PermissionError):
|
|
list_sample_options(db, ctx=_ctx(), user_info=_user('u2'))
|
|
|
|
|
|
def test_manager_can_link_sample_in_managed_region():
|
|
from app.services.market_freight import link_samples
|
|
db = _session()
|
|
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))
|
|
db.commit()
|
|
existing, ep = _sample(db, 'existing', sales='u9', manager='m1')
|
|
new_sample, _ = _sample(db, 'new', sales='u9', manager='x', region='managed')
|
|
freight = _freight(db, 'manager-link', [('cell_storage', existing, ep)])
|
|
detail = link_samples(db, freight.id, ctx=_ctx('manager', 'm1'), user_info=_user('m1'), items=_items(('cell_storage', new_sample)))
|
|
assert {item['sampleCode'] for item in detail['items']} == {'C-existing', 'C-new'}
|
|
|
|
|
|
def test_batch_paging_is_stable_and_total_counts_data_scoped_batches():
|
|
from app.services.market_freight import list_freights
|
|
db = _session()
|
|
first, fp = _sample(db, 'first')
|
|
second, sp = _sample(db, 'second')
|
|
hidden, hp = _sample(db, 'hidden-page', sales='u2')
|
|
f1 = _freight(db, 'first', [('cell_storage', first, fp)])
|
|
f2 = _freight(db, 'second', [('cell_storage', second, sp)])
|
|
f3 = _freight(db, 'hidden-page', [('cell_storage', hidden, hp)])
|
|
f1.create_time = f2.create_time
|
|
f3.create_time = f2.create_time
|
|
db.commit()
|
|
page1, total1 = list_freights(db, ctx=_ctx(), user_info=_user(), page=1, page_size=1)
|
|
page2, total2 = list_freights(db, ctx=_ctx(), user_info=_user(), page=2, page_size=1)
|
|
page3, total3 = list_freights(db, ctx=_ctx(), user_info=_user(), page=3, page_size=1)
|
|
assert total1 == total2 == total3 == 3
|
|
assert [page1[0]['id'], page2[0]['id'], page3[0]['id']] == [
|
|
'f-second',
|
|
'f-hidden-page',
|
|
'f-first',
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
|
|
def test_sample_options_choose_latest_party_once_before_count_and_paging(source):
|
|
from app.services.market_freight import list_sample_options
|
|
db = _session()
|
|
base = datetime(2026, 10, 10, 8, 0)
|
|
target, parent = _sample(db, f'duplicate-{source}', source=source, created=base)
|
|
_sample(db, f'other-{source}', source=source, created=base + timedelta(hours=1))
|
|
if source == 'cell_storage':
|
|
db.add(BizProtocolPartyCustomer(
|
|
id='zz-latest-cell-party', collection_protocol_id=parent.id,
|
|
mother_name='最新细胞客户', tenant_no='tenant-a', create_dept_id='',
|
|
create_time=base + timedelta(hours=2), del_flag=0,
|
|
))
|
|
latest_name = '最新细胞客户'
|
|
else:
|
|
db.add(BizResearchDonationParty(
|
|
id='zz-latest-donation-party', research_donation_id=parent.id,
|
|
mother_name='最新科研客户', tenant_no='tenant-a', create_dept_id='',
|
|
create_time=base + timedelta(hours=2), del_flag=0,
|
|
))
|
|
latest_name = '最新科研客户'
|
|
db.commit()
|
|
|
|
page1, total1 = list_sample_options(
|
|
db, ctx=_ctx(), user_info=_user(), page=1, page_size=1,
|
|
sample_source=source,
|
|
)
|
|
page2, total2 = list_sample_options(
|
|
db, ctx=_ctx(), user_info=_user(), page=2, page_size=1,
|
|
sample_source=source,
|
|
)
|
|
rows = page1 + page2
|
|
|
|
assert total1 == total2 == 2
|
|
assert sum(row['sampleId'] == target.id for row in rows) == 1
|
|
target_row = next(row for row in rows if row['sampleId'] == target.id)
|
|
assert target_row['customerName'] == latest_name
|
|
|
|
|
|
def test_mysql_lock_queries_are_current_reads_with_stable_global_order():
|
|
from app.services.market_freight import (
|
|
_active_item_lock_query,
|
|
_collection_lock_query,
|
|
_leader_lock_query,
|
|
_sample_parent_lock_query,
|
|
)
|
|
db = _session()
|
|
ctx = _ctx()
|
|
sample_sql = str(
|
|
_sample_parent_lock_query(
|
|
db, 'cell_storage', ['z', 'a'], ctx
|
|
).statement.compile(dialect=mysql.dialect())
|
|
).lower()
|
|
collection_sql = str(
|
|
_collection_lock_query(
|
|
db, 'cell_storage', ['p-z', 'p-a'], ctx
|
|
).statement.compile(dialect=mysql.dialect())
|
|
).lower()
|
|
item_sql = str(
|
|
_active_item_lock_query(
|
|
db, ctx, freight_id='f1', keys=[('cell_storage', 'z')]
|
|
).statement.compile(dialect=mysql.dialect())
|
|
).lower()
|
|
leader_sql = str(
|
|
_leader_lock_query(db, _ctx('manager', 'm1')).statement.compile(
|
|
dialect=mysql.dialect()
|
|
)
|
|
).lower()
|
|
|
|
for sql in (sample_sql, collection_sql, item_sql, leader_sql):
|
|
assert ' for update' in sql
|
|
assert ' order by ' in sql
|
|
assert 'tenant_no' in sql and 'del_flag' in sql
|
|
assert 'sales_user_id' in collection_sql
|
|
assert 'biz_protocol_collection_info' in collection_sql
|
|
assert 'biz_collection_region_staff' in leader_sql
|
|
|
|
|
|
def test_all_write_paths_use_batch_graph_item_lock_order():
|
|
source = (Path(__file__).parents[1] / 'app/services/market_freight.py').read_text()
|
|
for start_name, end_name in (
|
|
('def update_freight', 'def link_samples'),
|
|
('def link_samples', 'def unlink_samples'),
|
|
('def unlink_samples', 'def delete_freight'),
|
|
('def delete_freight', None),
|
|
):
|
|
block = source[source.index(start_name):]
|
|
if end_name:
|
|
block = block[:block.index(end_name)]
|
|
assert block.index('_lock_batch_header') < block.index('_lock_sample_graph')
|
|
assert block.index('_lock_sample_graph') < block.index('_lock_active_items')
|
|
create_block = source[source.index('def create_freight'):source.index('def update_freight')]
|
|
assert create_block.index('_lock_sample_graph') < create_block.index('_lock_active_items')
|
|
|
|
|
|
def test_delete_freight_cascades_when_all_items_visible():
|
|
from app.services.market_freight import delete_freight, list_freights, list_sample_options
|
|
db = _session()
|
|
own, op = _sample(db, 'del-own')
|
|
freight = _freight(db, 'del-own', [('cell_storage', own, op)])
|
|
result = delete_freight(db, freight.id, ctx=_ctx(), user_info=_user())
|
|
assert result == {'id': freight.id, 'visibleItemCount': 0, 'disappeared': True}
|
|
assert db.query(BizSampleFreight).filter_by(id=freight.id).one().del_flag == 1
|
|
assert db.query(BizSampleFreightItem).filter_by(freight_id=freight.id, del_flag=0).count() == 0
|
|
assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 0
|
|
options, total = list_sample_options(db, ctx=_ctx(), user_info=_user(), page=1, page_size=20)
|
|
assert total >= 1
|
|
assert any(row['sampleId'] == own.id and not row.get('freightId') for row in options)
|
|
|
|
|
|
def test_delete_freight_rejects_when_outside_scope_items_exist():
|
|
from app.services.market_freight import delete_freight
|
|
db = _session()
|
|
own, op = _sample(db, 'del-vis')
|
|
other, hp = _sample(db, 'del-hid', sales='u2')
|
|
freight = _freight(db, 'del-mixed', [('cell_storage', own, op), ('cell_storage', other, hp)])
|
|
with pytest.raises(PermissionError, match='全部'):
|
|
delete_freight(db, freight.id, ctx=_ctx(), user_info=_user())
|
|
assert db.query(BizSampleFreight).filter_by(id=freight.id).one().del_flag == 0
|
|
assert db.query(BizSampleFreightItem).filter_by(freight_id=freight.id, del_flag=0).count() == 2
|
|
|
|
|
|
def test_web_admin_delete_freight_cascades_items_and_allows_relink():
|
|
from app.crud import sample_freight as freight_crud
|
|
db = _session()
|
|
own, op = _sample(db, 'web-del')
|
|
freight = _freight(db, 'web-del', [('cell_storage', own, op)])
|
|
empty = _freight(db, 'web-empty', [])
|
|
result = freight_crud.delete_freight(
|
|
db, freight.id, tenant_no='tenant-a', user_id='u1'
|
|
)
|
|
assert result == {'id': freight.id, 'deleted': True, 'linkedSampleCount': 0}
|
|
assert db.query(BizSampleFreight).filter_by(id=freight.id).one().del_flag == 1
|
|
assert db.query(BizSampleFreightItem).filter_by(freight_id=freight.id, del_flag=0).count() == 0
|
|
empty_result = freight_crud.delete_freight(
|
|
db, empty.id, tenant_no='tenant-a', user_id='u1'
|
|
)
|
|
assert empty_result['deleted'] is True
|
|
assert db.query(BizSampleFreight).filter_by(id=empty.id).one().del_flag == 1
|
|
created = freight_crud.create_freight(
|
|
db,
|
|
tenant_no='tenant-a',
|
|
user_id='u1',
|
|
transport_mode_raw='sf',
|
|
freight_amount='8.00',
|
|
items=[{'sampleSource': 'cell_storage', 'sampleId': own.id, 'protocolId': op.id}],
|
|
)
|
|
assert created['id']
|
|
assert any(
|
|
item.get('sampleId') == own.id for item in (created.get('items') or [])
|
|
)
|
|
|
|
|
|
def test_header_update_rejects_assignment_changed_after_prior_visible_read():
|
|
from app.services.market_freight import get_freight_detail, update_freight
|
|
db = _session()
|
|
sample, parent = _sample(db, 'scope-race')
|
|
freight = _freight(db, 'scope-race', [('cell_storage', sample, parent)])
|
|
assert get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())['visibleItemCount'] == 1
|
|
|
|
collection = db.query(BizProtocolCollectionInfo).filter_by(
|
|
collection_protocol_id=parent.id
|
|
).one()
|
|
db.query(BizProtocolCollectionInfo).filter_by(id=collection.id).update(
|
|
{'sales_user_id': 'u2'}, synchronize_session=False
|
|
)
|
|
assert collection.sales_user_id == 'u1'
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
update_freight(
|
|
db, freight.id, ctx=_ctx(), user_info=_user(), freight_amount='55.00'
|
|
)
|
|
assert exc.value.status_code == 404
|
|
assert db.query(BizSampleFreight).filter_by(id=freight.id).one().freight_amount == Decimal('12.30')
|
|
|
|
|
|
def test_final_item_lock_rejects_participant_snapshot_change():
|
|
from app.services.market_freight import _lock_active_items
|
|
db = _session()
|
|
first, fp = _sample(db, 'snapshot-first')
|
|
second, sp = _sample(db, 'snapshot-second')
|
|
freight = _freight(db, 'snapshot', [('cell_storage', first, fp)])
|
|
expected = [('cell_storage', first.id)]
|
|
db.add(BizSampleFreightItem(
|
|
id='fi-snapshot-race', freight_id=freight.id,
|
|
sample_source='cell_storage', sample_id=second.id,
|
|
sample_code=second.sample_code, protocol_id=sp.id,
|
|
tenant_no='tenant-a', create_dept_id='', del_flag=0,
|
|
))
|
|
db.commit()
|
|
|
|
with pytest.raises(PermissionError, match='发生变化'):
|
|
_lock_active_items(
|
|
db, _ctx(), freight_id=freight.id,
|
|
expected_batch_keys=expected,
|
|
)
|
|
|
|
|
|
def test_lock_graph_does_not_rely_on_exists_scope_for_current_field_values():
|
|
source = (Path(__file__).parents[1] / 'app/services/market_freight.py').read_text()
|
|
block = source[source.index('def _lock_sample_graph'):source.index('def _lock_active_items')]
|
|
assert 'apply_cell_protocol_scope' not in block
|
|
assert 'apply_donation_scope' not in block
|
|
assert '_current_field_visible_parent_ids' in block
|
|
assert '_current_data_scope_parent_ids' in block
|
|
|
|
|
|
def test_manager_header_update_rejects_current_leader_revocation():
|
|
from app.services.market_freight import get_freight_detail, update_freight
|
|
db = _session()
|
|
leader = BizCollectionRegionStaff(
|
|
id='leader-race', region_id='managed-race', user_id='m1', is_leader=1,
|
|
tenant_no='tenant-a', create_dept_id='', del_flag=0,
|
|
)
|
|
db.add(leader)
|
|
db.commit()
|
|
sample, parent = _sample(
|
|
db, 'leader-race', sales='u9', manager='other', region='managed-race'
|
|
)
|
|
freight = _freight(db, 'leader-race', [('cell_storage', sample, parent)])
|
|
ctx = _ctx('manager', 'm1')
|
|
assert get_freight_detail(db, freight.id, ctx=ctx, user_info=_user('m1'))['visibleItemCount'] == 1
|
|
db.query(BizCollectionRegionStaff).filter_by(id=leader.id).update(
|
|
{'is_leader': 0}, synchronize_session=False
|
|
)
|
|
assert leader.is_leader == 1
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
update_freight(
|
|
db, freight.id, ctx=ctx, user_info=_user('m1'), freight_amount='88.00'
|
|
)
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
def test_header_update_uses_current_role_data_scope_not_prior_snapshot():
|
|
from app.services.market_freight import get_freight_detail, update_freight
|
|
db = _session()
|
|
now = datetime(2026, 10, 11, 8, 0)
|
|
db.add_all([
|
|
SysUser(
|
|
id='scope-user', dept_id='dept-current', user_no='scope-user',
|
|
nick_name='范围用户', password='', tenant_no='tenant-a',
|
|
create_dept_id='', create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
SysRole(
|
|
id='scope-role', role_name='范围角色', data_scope='1', status='1',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysUserRole(
|
|
id='scope-user-role', user_id='scope-user', role_id='scope-role',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
])
|
|
db.commit()
|
|
sample, parent = _sample(db, 'data-scope', sales='scope-user')
|
|
parent.create_by = 'another-user'
|
|
parent.create_dept_id = 'another-dept'
|
|
db.commit()
|
|
freight = _freight(db, 'data-scope', [('cell_storage', sample, parent)])
|
|
ctx = _ctx('specialist', 'scope-user')
|
|
user = {'user_id': 'scope-user', 'tenant_no': 'tenant-a', 'role': 'admin'}
|
|
assert get_freight_detail(db, freight.id, ctx=ctx, user_info=user)['visibleItemCount'] == 1
|
|
cached_role = db.query(SysRole).filter_by(id='scope-role').one()
|
|
db.query(SysRole).filter_by(id='scope-role').update(
|
|
{'data_scope': '4'}, synchronize_session=False
|
|
)
|
|
assert cached_role.data_scope == '1'
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
update_freight(
|
|
db, freight.id, ctx=ctx, user_info=user, freight_amount='77.00'
|
|
)
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
def test_current_data_scope_preserves_post_roles_custom_scope_and_union():
|
|
from app.services.market_freight import _current_data_scope_parent_ids
|
|
db = _session()
|
|
now = datetime(2026, 10, 11, 8, 0)
|
|
db.add_all([
|
|
SysUser(
|
|
id='union-user', dept_id='union-home', user_no='union-user',
|
|
nick_name='并集用户', password='', tenant_no='tenant-a',
|
|
create_dept_id='', create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
SysRole(
|
|
id='custom-role', role_name='自定义范围', data_scope='5', status='1',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysRole(
|
|
id='self-role', role_name='本人范围', data_scope='4', status='1',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysUserRole(
|
|
id='union-direct', user_id='union-user', role_id='custom-role',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysRoleDataScopeDetail(
|
|
id='union-custom-detail', role_id='custom-role', scope_type='dept',
|
|
scope_value='custom-dept', tenant_no='tenant-a', create_dept_id='',
|
|
create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
SysPost(
|
|
id='union-post', post_name='继承职位', post_code='union-post',
|
|
status='1', tenant_no='tenant-a', create_dept_id='',
|
|
create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
SysUserPost(
|
|
id='union-user-post', user_id='union-user', post_id='union-post',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysPostRole(
|
|
id='union-post-role', post_id='union-post', role_id='self-role',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
])
|
|
db.commit()
|
|
_, custom_parent = _sample(db, 'union-custom')
|
|
_, self_parent = _sample(db, 'union-self')
|
|
_, denied_parent = _sample(db, 'union-denied')
|
|
custom_parent.create_dept_id = 'custom-dept'
|
|
self_parent.create_by = 'union-user'
|
|
denied_parent.create_dept_id = 'other-dept'
|
|
denied_parent.create_by = 'other-user'
|
|
db.commit()
|
|
|
|
visible = _current_data_scope_parent_ids(
|
|
db,
|
|
[custom_parent, self_parent, denied_parent],
|
|
_ctx('specialist', 'union-user'),
|
|
{'user_id': 'union-user', 'tenant_no': 'tenant-a', 'role': 'admin'},
|
|
)
|
|
|
|
assert visible == {custom_parent.id, self_parent.id}
|
|
|
|
|
|
def test_current_data_scope_preserves_department_descendant_scope():
|
|
from app.services.market_freight import _current_data_scope_parent_ids
|
|
db = _session()
|
|
now = datetime(2026, 10, 11, 8, 0)
|
|
db.add_all([
|
|
SysUser(
|
|
id='dept-user', dept_id='dept-root', user_no='dept-user',
|
|
nick_name='部门用户', password='', tenant_no='tenant-a',
|
|
create_dept_id='', create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
SysRole(
|
|
id='dept-role', role_name='部门以下', data_scope='3', status='1',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysUserRole(
|
|
id='dept-user-role', user_id='dept-user', role_id='dept-role',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysDept(
|
|
id='dept-root', parent_id='0', dept_name='根部门', status='1',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysDept(
|
|
id='dept-child', parent_id='dept-root', dept_name='子部门', status='1',
|
|
tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
])
|
|
db.commit()
|
|
_, child_parent = _sample(db, 'dept-child')
|
|
_, denied_parent = _sample(db, 'dept-denied')
|
|
child_parent.create_dept_id = 'dept-child'
|
|
denied_parent.create_dept_id = 'other-dept'
|
|
db.commit()
|
|
|
|
visible = _current_data_scope_parent_ids(
|
|
db,
|
|
[child_parent, denied_parent],
|
|
_ctx('specialist', 'dept-user'),
|
|
{'user_id': 'dept-user', 'tenant_no': 'tenant-a', 'role': 'admin'},
|
|
)
|
|
|
|
assert visible == {child_parent.id}
|
|
|
|
|
|
def test_locked_data_scope_ignores_payload_superadmin_across_tenants():
|
|
from app.services.market_freight import (
|
|
_current_data_scope_parent_ids,
|
|
get_freight_detail,
|
|
)
|
|
db = _session()
|
|
now = datetime(2026, 10, 11, 8, 0)
|
|
db.add_all([
|
|
SysUser(
|
|
id='cross-user', dept_id='cross-dept', user_no='cross-user',
|
|
nick_name='跨租户用户', password='', tenant_no='tenant-a',
|
|
create_dept_id='', create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
SysRole(
|
|
id='default-super-role', role_name='超级管理员', data_scope='1',
|
|
status='1', tenant_no='zkxw', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysUserRole(
|
|
id='default-super-link', user_id='cross-user',
|
|
role_id='default-super-role', tenant_no='zkxw', create_dept_id='',
|
|
create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
SysRole(
|
|
id='business-self-role', role_name='业务专员', data_scope='4',
|
|
status='1', tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysUserRole(
|
|
id='business-self-link', user_id='cross-user',
|
|
role_id='business-self-role', tenant_no='tenant-a',
|
|
create_dept_id='', create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
])
|
|
db.commit()
|
|
sample, parent = _sample(
|
|
db, 'cross-tenant-scope', sales='cross-user'
|
|
)
|
|
parent.create_by = 'another-user'
|
|
parent.create_dept_id = 'another-dept'
|
|
db.commit()
|
|
ctx = _ctx('specialist', 'cross-user')
|
|
forged_default_tenant_payload = {
|
|
'user_id': 'cross-user', 'tenant_no': 'tenant-a', 'role': 'super_admin'
|
|
}
|
|
freight = _freight(
|
|
db, 'cross-tenant-scope', [('cell_storage', sample, parent)]
|
|
)
|
|
|
|
assert _current_data_scope_parent_ids(
|
|
db, [parent], ctx, forged_default_tenant_payload
|
|
) == set()
|
|
with pytest.raises(HTTPException) as exc:
|
|
get_freight_detail(
|
|
db, freight.id, ctx=ctx, user_info=forged_default_tenant_payload
|
|
)
|
|
assert exc.value.status_code == 404
|
|
|
|
db.add_all([
|
|
SysRole(
|
|
id='business-super-role', role_name='超级管理员', data_scope='4',
|
|
status='1', tenant_no='tenant-a', create_dept_id='', create_time=now,
|
|
update_time=now, del_flag=0,
|
|
),
|
|
SysPost(
|
|
id='business-super-post', post_name='业务租户超级管理职位',
|
|
post_code='business-super-post', status='1', tenant_no='tenant-a',
|
|
create_dept_id='', create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
SysUserPost(
|
|
id='business-super-user-post', user_id='cross-user',
|
|
post_id='business-super-post', tenant_no='tenant-a',
|
|
create_dept_id='', create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
SysPostRole(
|
|
id='business-super-post-role', post_id='business-super-post',
|
|
role_id='business-super-role', tenant_no='tenant-a',
|
|
create_dept_id='', create_time=now, update_time=now, del_flag=0,
|
|
),
|
|
])
|
|
db.commit()
|
|
|
|
assert _current_data_scope_parent_ids(
|
|
db, [parent], ctx, forged_default_tenant_payload
|
|
) == {parent.id}
|
|
assert get_freight_detail(
|
|
db, freight.id, ctx=ctx, user_info=forged_default_tenant_payload
|
|
)['visibleItemCount'] == 1
|