Files
zkxw/backend/tests/test_market_freight_scope.py
T
zlwuandCursor ac8e471aca fix: align sample registration, freight scope, and OA datetime display
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>
2026-10-20 17:30:30 +08:00

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