安全:补齐业务批次归属校验

This commit is contained in:
焦龙言
2026-09-23 11:49:35 +08:00
parent 97b4dc8508
commit 5a76fe7521
10 changed files with 509 additions and 58 deletions
@@ -37,6 +37,8 @@ from module_business.entity.vo.operation_vo import (
)
from module_business.service.access_service import AccessService
from module_business.service.filling_service import FillingService
from module_business.service.test_batch_access_service import TestBatchAccessService
from tests.module_business.test_batch_fixtures import make_test_scope
def integrity_error() -> IntegrityError:
@@ -78,6 +80,7 @@ def make_pallet(
qr_token: str = 'qr-2',
capacity_packages: int = 20,
asset_status: str = '0',
test_batch_id: int | None = None,
) -> BizPallet:
return BizPallet(
pallet_id=pallet_id,
@@ -85,6 +88,7 @@ def make_pallet(
qr_token=qr_token,
capacity_packages=capacity_packages,
asset_status=asset_status,
test_batch_id=test_batch_id,
)
@@ -99,6 +103,7 @@ def make_inventory(
remaining_packages: int = 0,
last_event_id: int | None = 40,
version: int = 3,
test_batch_id: int | None = None,
) -> BizPalletInventory:
return BizPalletInventory(
pallet_id=pallet_id,
@@ -111,6 +116,7 @@ def make_inventory(
last_event_id=last_event_id,
version=version,
updated_at=datetime(2026, 7, 25, 9, 0),
test_batch_id=test_batch_id,
)
@@ -120,12 +126,14 @@ def make_strain(
status: str = '0',
code: str = 'S-011',
name: str = '香菇 11',
test_batch_id: int | None = None,
) -> BizStrain:
return BizStrain(
strain_id=strain_id,
strain_code=code,
strain_name=name,
status=status,
test_batch_id=test_batch_id,
)
@@ -135,6 +143,7 @@ def make_filling(
warehouse_id: int = 7,
idempotency_key: str = 'fill-key',
request_hash: str | None = None,
test_batch_id: int | None = None,
) -> BizFillingRecord:
page = make_page(warehouse_id=warehouse_id, idempotency_key=idempotency_key)
return BizFillingRecord(
@@ -157,6 +166,7 @@ def make_filling(
create_time=datetime(2026, 7, 26, 10, 30),
update_by='user-7',
update_time=datetime(2026, 7, 26, 10, 30),
test_batch_id=test_batch_id,
)
@@ -392,6 +402,24 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
await FillingService.scan_filling_pallet_services(self.db, 'qr-2', make_user(1), TestBatchScope.formal())
self.assertEqual('托盘库存不存在', raised.exception.message)
async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None:
scope = make_test_scope(4)
with (
patch.object(
InventoryDao,
'get_pallet_by_qr_token',
new=AsyncMock(return_value=make_pallet(test_batch_id=5)),
),
patch.object(
MasterDao,
'get_pallet_inventory',
new=AsyncMock(return_value=make_inventory(test_batch_id=4)),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
):
await FillingService.scan_filling_pallet_services(self.db, 'qr-2', self.user, scope)
class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
@@ -401,7 +429,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.pallet = make_pallet()
self.inventory = make_inventory()
self.strain = make_strain()
self.warehouse = SimpleNamespace(warehouse_id=7, status='0')
self.warehouse = SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)
self.fixed_time = datetime(2026, 7, 26, 10, 30)
async def test_fill_locks_inventory_then_pallet_and_writes_one_atomic_event( # noqa: PLR0915
@@ -549,6 +577,29 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.assertEqual('装填成功', result.message)
self.assertEqual(31, result.result)
async def test_test_scope_write_rejects_cross_batch_pallet_and_rolls_back(self) -> None:
scope = make_test_scope(4)
add_filling = AsyncMock()
with (
patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock()),
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=None)),
patch.object(
InventoryDao,
'get_pallet_by_qr_token',
new=AsyncMock(return_value=make_pallet(test_batch_id=5)),
),
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock()),
patch.object(OperationDao, 'add_filling_record', new=add_filling),
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
):
await FillingService.fill_pallet_services(self.db, self.page, self.user, scope)
add_filling.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def _assert_fill_rejected(
self,
*,
@@ -597,7 +648,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
actions.append('warehouse-read')
return SimpleNamespace(warehouse_id=7, status='1')
return SimpleNamespace(warehouse_id=7, status='1', test_batch_id=None)
warehouse_read = AsyncMock(side_effect=lock_warehouse)
strain_read = AsyncMock()
@@ -54,6 +54,7 @@ from module_business.entity.vo.operation_vo import (
from module_business.service import inbound_service as inbound_service_module
from module_business.service.access_service import AccessService
from module_business.service.inbound_service import InboundService
from tests.module_business.test_batch_fixtures import make_test_scope
def integrity_error() -> IntegrityError:
@@ -75,6 +76,7 @@ def make_pallet(pallet_id: int, *, asset_status: str = '0') -> BizPallet:
qr_token=f'qr-{pallet_id}',
capacity_packages=20,
asset_status=asset_status,
test_batch_id=None,
)
@@ -88,6 +90,7 @@ def make_inventory(
active_filling_id: int | None = None,
remaining_packages: int = 0,
version: int = 3,
test_batch_id: int | None = None,
) -> BizPalletInventory:
return BizPalletInventory(
pallet_id=pallet_id,
@@ -98,6 +101,7 @@ def make_inventory(
active_filling_id=active_filling_id,
remaining_packages=remaining_packages,
version=version,
test_batch_id=test_batch_id,
)
@@ -109,6 +113,7 @@ def make_inbound(
status: str = 'DRAFT',
idempotency_key: str | None = None,
request_hash: str | None = None,
test_batch_id: int | None = None,
) -> BizInboundOrder:
inbound = BizInboundOrder(
inbound_id=inbound_id,
@@ -117,12 +122,14 @@ def make_inbound(
status=status,
confirm_idempotency_key=idempotency_key,
confirm_request_hash=request_hash,
test_batch_id=test_batch_id,
)
inbound.items = [
BizInboundItem(
inbound_item_id=index,
inbound_id=inbound_id,
pallet_id=pallet_id,
test_batch_id=test_batch_id,
)
for index, pallet_id in enumerate(pallet_ids, start=1)
]
@@ -228,7 +235,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=access)),
patch.object(
@@ -275,6 +282,32 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
self.assertTrue(result.is_success)
self.db.rollback.assert_not_awaited()
async def test_add_rejects_cross_batch_locked_inventory_and_rolls_back(self) -> None:
inventory = make_inventory(2)
inventory.test_batch_id = None
pallet = make_pallet(2)
pallet.test_batch_id = 4
with (
patch.object(inbound_service_module.TestBatchAccessService, 'lock_active_batch', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=4)),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[pallet])),
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
):
await InboundService.add_inbound_services(
self.db,
InboundCreateModel(warehouseId=7, palletIds=[2]),
self.user,
make_test_scope(4),
)
self.db.rollback.assert_awaited_once()
self.db.commit.assert_not_awaited()
async def test_add_rejects_invalid_pallet_before_any_order_or_event_write(self) -> None:
page = InboundCreateModel(warehouseId=7, palletIds=[2, 5])
invalid_inventory = make_inventory(5, warehouse_id=7)
@@ -285,7 +318,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(
@@ -316,7 +349,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
lock = AsyncMock()
cases = (
(None, ServiceException),
(SimpleNamespace(warehouse_id=7, status='1'), ServiceException),
(SimpleNamespace(warehouse_id=7, status='1', test_batch_id=None), ServiceException),
)
for warehouse, exception_type in cases:
with self.subTest(warehouse=warehouse):
@@ -338,7 +371,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(
AccessService,
@@ -386,7 +419,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
replace_items = AsyncMock()
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
warehouse_read = AsyncMock(
return_value=SimpleNamespace(warehouse_id=7, status='0'),
return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None),
)
with (
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
@@ -425,7 +458,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=8, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=8, status='0', test_batch_id=None)),
),
patch.object(
InventoryDao,
@@ -497,6 +530,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
side_effect=lambda _db, warehouse_id, _scope, **_kwargs: SimpleNamespace(
warehouse_id=warehouse_id,
status='0',
test_batch_id=None,
)
),
),
@@ -655,7 +689,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(
@@ -680,7 +714,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=integrity_error())),
@@ -830,9 +864,9 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
async def test_warehouse_options_filter_disabled_and_unauthorized_records(self) -> None:
warehouses = [
SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0'),
SimpleNamespace(warehouse_id=3, warehouse_code='W3', warehouse_name='三仓', status='1'),
SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0'),
SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0', test_batch_id=None),
SimpleNamespace(warehouse_id=3, warehouse_code='W3', warehouse_name='三仓', status='1', test_batch_id=None),
SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0', test_batch_id=None),
]
with (
patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)) as list_warehouse,
@@ -849,8 +883,8 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
async def test_admin_warehouse_options_include_all_active_records(self) -> None:
warehouses = [
SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0'),
SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0'),
SimpleNamespace(warehouse_id=2, warehouse_code='W2', warehouse_name='二仓', status='0', test_batch_id=None),
SimpleNamespace(warehouse_id=5, warehouse_code='W5', warehouse_name='五仓', status='0', test_batch_id=None),
]
scope = AsyncMock()
with (
@@ -894,7 +928,7 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)) as get_pallet,
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)) as get_inventory,
@@ -938,7 +972,7 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)),
@@ -969,7 +1003,7 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase):
)
get_pallet.assert_not_awaited()
for warehouse in (None, SimpleNamespace(warehouse_id=7, status='1')):
for warehouse in (None, SimpleNamespace(warehouse_id=7, status='1', test_batch_id=None)):
with self.subTest(warehouse=warehouse):
get_pallet.reset_mock()
with (
@@ -1079,7 +1113,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
actions.append('warehouse-lock')
return SimpleNamespace(warehouse_id=7, status='0')
return SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)
def transition(current: PalletFlowState, target: PalletFlowState) -> None:
actions.append(f'transition-{current.value}-{target.value}')
@@ -1282,7 +1316,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
) as warehouse_lock,
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
patch.object(OperationDao, 'update_inbound', new=update),
@@ -1343,7 +1377,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(PalletStateMachine, 'ensure_transition', side_effect=ValueError('invalid')),
patch.object(OperationDao, 'update_inbound', new=update),
@@ -1378,7 +1412,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
) as warehouse_lock,
):
result = await InboundService.confirm_inbound_services(
@@ -1439,7 +1473,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
):
@@ -1486,7 +1520,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='1')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='1', test_batch_id=None)),
) as warehouse_lock,
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
self.assertRaises(PermissionException) as raised,
@@ -1564,7 +1598,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
@@ -1600,7 +1634,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
@@ -1674,7 +1708,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
):
@@ -1737,7 +1771,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
) as warehouse_lock,
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
@@ -1777,7 +1811,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0', test_batch_id=None)),
),
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
@@ -45,6 +45,7 @@ from module_business.entity.vo.operation_vo import (
)
from module_business.service.access_service import AccessService
from module_business.service.test_batch_access_service import TestBatchAccessService
from tests.module_business.test_batch_fixtures import make_test_scope
RETURN_SERVICE_SPEC = importlib.util.find_spec('module_business.service.return_service')
RETURN_CONTROLLER_SPEC = importlib.util.find_spec('module_business.controller.return_controller')
@@ -70,13 +71,20 @@ def make_user(user_id: int = 7) -> CurrentUserModel:
)
def make_pallet(pallet_id: int, *, capacity: int = 20, asset_status: str = '0') -> BizPallet:
def make_pallet(
pallet_id: int,
*,
capacity: int = 20,
asset_status: str = '0',
test_batch_id: int | None = None,
) -> BizPallet:
return BizPallet(
pallet_id=pallet_id,
pallet_code=f'P-{pallet_id:03d}',
qr_token=f'qr-{pallet_id}',
capacity_packages=capacity,
asset_status=asset_status,
test_batch_id=test_batch_id,
)
@@ -90,6 +98,7 @@ def make_inventory(
filling_id: int | None = -1,
state: str = PalletFlowState.AT_SITE_PARTIAL.value,
version: int = 3,
test_batch_id: int | None = None,
) -> BizPalletInventory:
active_filling_id = (100 + pallet_id if remaining > 0 else None) if filling_id == -1 else filling_id
return BizPalletInventory(
@@ -101,10 +110,16 @@ def make_inventory(
remaining_packages=remaining,
flow_state=state,
version=version,
test_batch_id=test_batch_id,
)
def make_filling(pallet_id: int, *, warehouse_id: int = 3) -> BizFillingRecord:
def make_filling(
pallet_id: int,
*,
warehouse_id: int = 3,
test_batch_id: int | None = None,
) -> BizFillingRecord:
return BizFillingRecord(
filling_id=100 + pallet_id,
filling_no=f'FI-{pallet_id}',
@@ -121,6 +136,7 @@ def make_filling(pallet_id: int, *, warehouse_id: int = 3) -> BizFillingRecord:
request_hash='f' * 64,
operator_id=7,
operated_at=datetime(2026, 7, 1),
test_batch_id=test_batch_id,
)
@@ -133,6 +149,7 @@ def make_return_order(
request_hash: str = 'r' * 64,
confirm_key: str | None = None,
confirm_hash: str | None = None,
test_batch_id: int | None = None,
) -> BizReturnOrder:
order = BizReturnOrder(
return_id=return_id,
@@ -146,6 +163,7 @@ def make_return_order(
confirm_request_hash=confirm_hash,
requested_by=8,
requested_at=datetime(2026, 7, 25, 9),
test_batch_id=test_batch_id,
)
order.items = [
BizReturnItem(
@@ -154,6 +172,7 @@ def make_return_order(
pallet_id=pallet_id,
active_filling_id_snapshot=filling_id,
remaining_packages_snapshot=remaining,
test_batch_id=test_batch_id,
)
for index, (pallet_id, filling_id, remaining) in enumerate(snapshots, start=1)
]
@@ -573,6 +592,35 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(8, actual.remaining_packages)
self.assertEqual(['request'], actual.allowed_actions)
async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None:
scope = make_test_scope(4)
with (
patch.object(
InventoryDao,
'get_pallet_by_qr_token',
new=AsyncMock(return_value=make_pallet(2, test_batch_id=5)),
),
patch.object(
MasterDao,
'get_pallet_inventory',
new=AsyncMock(return_value=make_inventory(2, test_batch_id=4)),
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
OperationDao,
'get_filling_by_id',
new=AsyncMock(return_value=make_filling(2, test_batch_id=4)),
),
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
):
await ReturnService.scan_return_pallet_services(
self.db,
'qr-2',
ReturnScanQueryModel(mode='request'),
self.user,
scope,
)
async def test_confirm_scan_is_scoped_to_target_warehouse_and_exact_item_snapshot(self) -> None:
order = make_return_order(snapshots=((2, 102, 8),))
pallet = make_pallet(2)
@@ -687,11 +735,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
actions.append('warehouse-row')
return SimpleNamespace(status='0')
return SimpleNamespace(status='0', test_batch_id=None)
async def lock_site(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
actions.append('site-row')
return SimpleNamespace(status='0')
return SimpleNamespace(status='0', test_batch_id=None)
async def lock_site_mapping(*_args: Any, **_kwargs: Any) -> None:
actions.append('site-mapping')
@@ -772,8 +820,16 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_access),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
patch.object(OperationDao, 'add_return_order', new=AsyncMock(return_value=probe)),
patch.object(OperationDao, 'add_return_items', new=AsyncMock(side_effect=add_items)),
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
@@ -822,8 +878,16 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(return_value=filling)),
patch.object(OperationDao, 'get_return_for_update', new=order_lock),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=4)),
),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=4)),
),
patch.object(OperationDao, 'add_return_order', new=AsyncMock(side_effect=integrity_error())),
):
result = await ReturnService.request_return_services(self.db, self.page, self.user, scope)
@@ -886,7 +950,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
async def lock_site(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
actions.append('site-lock')
return SimpleNamespace(status='0')
return SimpleNamespace(status='0', test_batch_id=None)
key_read = AsyncMock(side_effect=read_key)
scope_read = AsyncMock(side_effect=read_scope)
@@ -1010,7 +1074,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=relocated)),
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_locks),
patch.object(OperationDao, 'get_active_filling_for_update', new=filling_locks),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
patch.object(OperationDao, 'add_return_order', new=AsyncMock()) as add_order,
patch.object(InventoryDao, 'add_event', new=AsyncMock()) as add_event,
):
@@ -1105,7 +1173,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
'get_active_filling_for_update',
new=AsyncMock(return_value=make_filling(2)),
),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
):
result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal())
@@ -1192,7 +1264,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
new=AsyncMock(return_value=probe),
),
patch.object(OperationDao, 'get_return_for_update', new=order_lock),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as inventory_locks,
):
result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal())
@@ -1271,8 +1347,16 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
'get_active_filling_for_update',
new=AsyncMock(return_value=make_filling(5)),
),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
):
try:
await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal())
@@ -1480,7 +1564,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
),
patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as locks,
):
result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal())
@@ -1567,7 +1655,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[10, 9])),
patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_return_for_update', new=order_lock),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
):
result = await ReturnService._recover_request_integrity_error(
@@ -1604,7 +1696,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=get_by_key),
patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
):
result = await ReturnService.request_return_services(self.db, self.page, admin, TestBatchScope.formal())
@@ -1822,7 +1918,11 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
),
patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as locks,
):
result = await ReturnService.confirm_return_services(
@@ -1871,7 +1971,11 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
),
patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
):
result = await ReturnService.confirm_return_services(self.db, 13, self.page, admin, TestBatchScope.formal())
@@ -1916,7 +2020,11 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
patch.object(OperationDao, 'get_return_by_id', new=route_read),
patch.object(OperationDao, 'get_return_by_confirm_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_return_for_update', new=order_lock),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
):
result = await ReturnService._recover_confirm_integrity_error(
@@ -55,6 +55,8 @@ from module_business.entity.vo.operation_vo import (
from module_business.service import shipment_service as shipment_service_module
from module_business.service.access_service import AccessService
from module_business.service.shipment_service import ShipmentService
from module_business.service.test_batch_access_service import TestBatchAccessService
from tests.module_business.test_batch_fixtures import make_test_scope
def make_user(user_id: int = 7) -> CurrentUserModel:
@@ -69,13 +71,20 @@ def integrity_error() -> IntegrityError:
return IntegrityError('statement', {}, RuntimeError('constraint'))
def make_pallet(pallet_id: int, *, capacity: int = 20, asset_status: str = '0') -> BizPallet:
def make_pallet(
pallet_id: int,
*,
capacity: int = 20,
asset_status: str = '0',
test_batch_id: int | None = None,
) -> BizPallet:
return BizPallet(
pallet_id=pallet_id,
pallet_code=f'P-{pallet_id:03d}',
qr_token=f'qr-{pallet_id}',
capacity_packages=capacity,
asset_status=asset_status,
test_batch_id=test_batch_id,
)
@@ -89,6 +98,7 @@ def make_inventory(
remaining_packages: int = 8,
flow_state: str = 'FILLED_IN_WAREHOUSE',
version: int = 3,
test_batch_id: int | None = None,
) -> BizPalletInventory:
return BizPalletInventory(
pallet_id=pallet_id,
@@ -99,10 +109,16 @@ def make_inventory(
remaining_packages=remaining_packages,
flow_state=flow_state,
version=version,
test_batch_id=test_batch_id,
)
def make_filling(pallet_id: int, *, warehouse_id: int = 7) -> BizFillingRecord:
def make_filling(
pallet_id: int,
*,
warehouse_id: int = 7,
test_batch_id: int | None = None,
) -> BizFillingRecord:
return BizFillingRecord(
filling_id=100 + pallet_id,
filling_no=f'FI-{pallet_id}',
@@ -119,6 +135,7 @@ def make_filling(pallet_id: int, *, warehouse_id: int = 7) -> BizFillingRecord:
request_hash='f' * 64,
operator_id=7,
operated_at=SimpleNamespace(),
test_batch_id=test_batch_id,
)
@@ -131,6 +148,7 @@ def make_shipment(
status: str = 'DRAFT',
idempotency_key: str | None = None,
request_hash: str | None = None,
test_batch_id: int | None = None,
) -> BizShipmentOrder:
shipment = BizShipmentOrder(
shipment_id=shipment_id,
@@ -140,12 +158,14 @@ def make_shipment(
status=status,
confirm_idempotency_key=idempotency_key,
confirm_request_hash=request_hash,
test_batch_id=test_batch_id,
)
shipment.items = [
BizShipmentItem(
shipment_item_id=index,
shipment_id=shipment_id,
pallet_id=pallet_id,
test_batch_id=test_batch_id,
)
for index, pallet_id in enumerate(pallet_ids, start=1)
]
@@ -756,6 +776,58 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual('无权访问该托盘', raised.exception.message)
async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None:
scope = make_test_scope(4)
query = ShipmentScanQueryModel(sourceWarehouseId=7, targetSiteId=9)
with (
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
test_batch_id=4,
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
test_batch_id=4,
)
),
),
patch.object(
InventoryDao,
'get_pallet_by_qr_token',
new=AsyncMock(return_value=make_pallet(2, test_batch_id=5)),
),
patch.object(
MasterDao,
'get_pallet_inventory',
new=AsyncMock(return_value=make_inventory(2, test_batch_id=4)),
),
patch.object(
OperationDao,
'get_filling_by_id',
new=AsyncMock(return_value=make_filling(2, test_batch_id=4)),
),
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
):
await ShipmentService.scan_shipment_pallet_services(self.db, 'qr-2', query, self.user, scope)
class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
@@ -792,6 +864,58 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual([pallet], pallets)
self.assertEqual([origin_filling], fillings)
async def test_test_scope_write_rejects_cross_batch_inventory_and_rolls_back(self) -> None:
scope = make_test_scope(4)
page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[2])
add_shipment = AsyncMock()
with (
patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock()),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
test_batch_id=4,
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
test_batch_id=4,
)
),
),
patch.object(
InventoryDao,
'get_inventories_for_update',
new=AsyncMock(return_value=[make_inventory(2, test_batch_id=5)]),
),
patch.object(
InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2, test_batch_id=4)])
),
patch.object(OperationDao, 'add_shipment', new=add_shipment),
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
):
await ShipmentService.add_shipment_services(self.db, page, self.user, scope)
add_shipment.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_add_only_persists_pallet_ids_with_null_snapshots(self) -> None: # noqa: PLR0915
page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[5, 2])
inventories = [make_inventory(2), make_inventory(5)]
@@ -100,6 +100,7 @@ def make_pallet(
qr_token: str = 'qr-2',
capacity_packages: int = 20,
asset_status: str = '0',
test_batch_id: int | None = None,
) -> BizPallet:
return BizPallet(
pallet_id=pallet_id,
@@ -107,6 +108,7 @@ def make_pallet(
qr_token=qr_token,
capacity_packages=capacity_packages,
asset_status=asset_status,
test_batch_id=test_batch_id,
)
@@ -119,6 +121,7 @@ def make_inventory(
remaining_packages: int = 8,
flow_state: str = PalletFlowState.AT_SITE_UNUSED.value,
version: int = 3,
test_batch_id: int | None = None,
) -> BizPalletInventory:
return BizPalletInventory(
pallet_id=pallet_id,
@@ -131,6 +134,7 @@ def make_inventory(
last_event_id=40,
version=version,
updated_at=datetime(2026, 7, 25, 9, 0),
test_batch_id=test_batch_id,
)
@@ -139,6 +143,7 @@ def make_filling(
*,
pallet_id: int = 2,
active_slot: str | None = 'ACTIVE',
test_batch_id: int | None = None,
) -> BizFillingRecord:
return BizFillingRecord(
filling_id=filling_id,
@@ -157,16 +162,23 @@ def make_filling(
request_hash='f' * 64,
operator_id=7,
operated_at=datetime(2026, 7, 25, 8, 0),
test_batch_id=test_batch_id,
)
def make_site(site_id: int = 9, *, status: str = '0') -> BizCustomerSite:
def make_site(
site_id: int = 9,
*,
status: str = '0',
test_batch_id: int | None = None,
) -> BizCustomerSite:
return BizCustomerSite(
site_id=site_id,
customer_id=3,
site_code=f'SITE-{site_id}',
site_name=f'现场 {site_id}',
status=status,
test_batch_id=test_batch_id,
)
@@ -175,6 +187,7 @@ def make_line(
*,
site_id: int = 9,
status: str = '0',
test_batch_id: int | None = None,
) -> BizProductionLine:
return BizProductionLine(
line_id=line_id,
@@ -182,6 +195,7 @@ def make_line(
line_code=f'L-{line_id}',
line_name=f'产线 {line_id}',
status=status,
test_batch_id=test_batch_id,
)
@@ -192,6 +206,7 @@ def make_usage(
line_id: int = 4,
idempotency_key: str = 'usage-key',
request_hash: str = 'a' * 64,
test_batch_id: int | None = None,
) -> BizUsageRecord:
return BizUsageRecord(
usage_id=usage_id,
@@ -206,6 +221,7 @@ def make_usage(
request_hash=request_hash,
operator_id=7,
occurred_at=datetime(2026, 7, 26, 11, 30),
test_batch_id=test_batch_id,
)
@@ -519,6 +535,29 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase):
self.assertEqual(5, result.remaining_packages)
self.assertEqual(['usage'], result.allowed_actions)
async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None:
scope = make_test_scope(4)
with (
patch.object(
InventoryDao,
'get_pallet_by_qr_token',
new=AsyncMock(return_value=make_pallet(test_batch_id=5)),
),
patch.object(
MasterDao,
'get_pallet_inventory',
new=AsyncMock(return_value=make_inventory(test_batch_id=4)),
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
OperationDao,
'get_filling_by_id',
new=AsyncMock(return_value=make_filling(test_batch_id=4)),
),
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
):
await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, scope)
async def test_scan_hides_missing_non_site_and_unauthorized_qr_for_normal_user(self) -> None:
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
@@ -1683,6 +1722,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
self.expired = False
self.pallet_id_reads = 0
self._pallet_id = pallet_id
self.test_batch_id = None
@property
def pallet_id(self) -> int: