1963 lines
88 KiB
Python
1963 lines
88 KiB
Python
import hashlib
|
|
import inspect
|
|
import json
|
|
import unittest
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from typing import Any, get_type_hints
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.routing import APIRoute
|
|
from sqlalchemy.exc import IntegrityError, MultipleResultsFound
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from common.aspect.interface_auth import CheckUserInterfaceAuth
|
|
from common.aspect.pre_auth import PreAuth
|
|
from common.context import RequestContext
|
|
from common.vo import (
|
|
CrudResponseModel,
|
|
DataResponseModel,
|
|
PageModel,
|
|
PageResponseModel,
|
|
ResponseBaseModel,
|
|
)
|
|
from exceptions.exception import PermissionException, ServiceException, ServiceWarning
|
|
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
|
|
from module_admin.service.log_service import LogQueueService
|
|
from module_admin.service.login_service import LoginService
|
|
from module_business.controller import inbound_controller as inbound_controller_module
|
|
from module_business.dao.access_dao import AccessDao
|
|
from module_business.dao.inventory_dao import InventoryDao
|
|
from module_business.dao.master_dao import MasterDao
|
|
from module_business.dao.operation_dao import OperationDao
|
|
from module_business.domain.enums import PalletEventType, PalletFlowState
|
|
from module_business.domain.pallet_state_machine import PalletStateMachine
|
|
from module_business.domain.test_batch_scope import TestBatchScope
|
|
from module_business.entity.do.master_do import BizPallet
|
|
from module_business.entity.do.operation_do import (
|
|
BizInboundItem,
|
|
BizInboundOrder,
|
|
BizPalletEvent,
|
|
BizPalletInventory,
|
|
)
|
|
from module_business.entity.vo.operation_vo import (
|
|
BusinessOptionModel,
|
|
ConfirmCommandModel,
|
|
InboundCreateModel,
|
|
InboundModel,
|
|
InboundPageQueryModel,
|
|
InboundScanQueryModel,
|
|
InboundUpdateModel,
|
|
PalletScanResponseModel,
|
|
)
|
|
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
|
|
|
|
|
|
def integrity_error() -> IntegrityError:
|
|
return IntegrityError('statement', {}, RuntimeError('constraint'))
|
|
|
|
|
|
def make_user(user_id: int = 7) -> CurrentUserModel:
|
|
return CurrentUserModel(
|
|
permissions=[],
|
|
roles=[],
|
|
user=UserInfoModel(userId=user_id, userName='admin' if user_id == 1 else f'user-{user_id}'),
|
|
)
|
|
|
|
|
|
def make_pallet(pallet_id: int, *, asset_status: str = '0') -> BizPallet:
|
|
return BizPallet(
|
|
pallet_id=pallet_id,
|
|
pallet_code=f'P-{pallet_id:03d}',
|
|
qr_token=f'qr-{pallet_id}',
|
|
capacity_packages=20,
|
|
asset_status=asset_status,
|
|
)
|
|
|
|
|
|
def make_inventory(
|
|
pallet_id: int,
|
|
*,
|
|
flow_state: str = 'UNSTOCKED',
|
|
warehouse_id: int | None = None,
|
|
site_id: int | None = None,
|
|
current_line_id: int | None = None,
|
|
active_filling_id: int | None = None,
|
|
remaining_packages: int = 0,
|
|
version: int = 3,
|
|
) -> BizPalletInventory:
|
|
return BizPalletInventory(
|
|
pallet_id=pallet_id,
|
|
flow_state=flow_state,
|
|
warehouse_id=warehouse_id,
|
|
site_id=site_id,
|
|
current_line_id=current_line_id,
|
|
active_filling_id=active_filling_id,
|
|
remaining_packages=remaining_packages,
|
|
version=version,
|
|
)
|
|
|
|
|
|
def make_inbound(
|
|
inbound_id: int = 11,
|
|
*,
|
|
warehouse_id: int = 7,
|
|
pallet_ids: tuple[int, ...] = (5, 2),
|
|
status: str = 'DRAFT',
|
|
idempotency_key: str | None = None,
|
|
request_hash: str | None = None,
|
|
) -> BizInboundOrder:
|
|
inbound = BizInboundOrder(
|
|
inbound_id=inbound_id,
|
|
inbound_no=f'IN-{inbound_id}',
|
|
warehouse_id=warehouse_id,
|
|
status=status,
|
|
confirm_idempotency_key=idempotency_key,
|
|
confirm_request_hash=request_hash,
|
|
)
|
|
inbound.items = [
|
|
BizInboundItem(
|
|
inbound_item_id=index,
|
|
inbound_id=inbound_id,
|
|
pallet_id=pallet_id,
|
|
)
|
|
for index, pallet_id in enumerate(pallet_ids, start=1)
|
|
]
|
|
return inbound
|
|
|
|
|
|
class TestInboundPublicContract(unittest.TestCase):
|
|
def test_public_business_methods_require_explicit_test_scope(self) -> None:
|
|
for method_name in (
|
|
'get_inbound_list_services',
|
|
'inbound_detail_services',
|
|
'get_warehouse_options_services',
|
|
'scan_inbound_pallet_services',
|
|
'add_inbound_services',
|
|
'edit_inbound_services',
|
|
'delete_inbound_services',
|
|
'confirm_inbound_services',
|
|
):
|
|
with self.subTest(method=method_name):
|
|
self.assertIn('test_scope', inspect.signature(getattr(InboundService, method_name)).parameters)
|
|
|
|
def test_service_and_controller_are_available(self) -> None:
|
|
self.assertIsNotNone(InboundService)
|
|
self.assertIsNotNone(inbound_controller_module.inbound_controller)
|
|
|
|
def test_confirm_signature_and_types_are_exact(self) -> None:
|
|
signature = inspect.signature(InboundService.confirm_inbound_services)
|
|
self.assertEqual(
|
|
('query_db', 'inbound_id', 'page_object', 'current_user', 'test_scope'),
|
|
tuple(signature.parameters),
|
|
)
|
|
hints = get_type_hints(InboundService.confirm_inbound_services)
|
|
self.assertIs(AsyncSession, hints['query_db'])
|
|
self.assertIs(int, hints['inbound_id'])
|
|
self.assertIs(ConfirmCommandModel, hints['page_object'])
|
|
self.assertIs(CurrentUserModel, hints['current_user'])
|
|
self.assertIs(CrudResponseModel, hints['return'])
|
|
|
|
def test_confirm_hash_is_compact_sorted_sha256_and_excludes_key(self) -> None:
|
|
expected_payload = json.dumps(
|
|
{'inbound_id': 11},
|
|
sort_keys=True,
|
|
separators=(',', ':'),
|
|
).encode()
|
|
expected = hashlib.sha256(expected_payload).hexdigest()
|
|
|
|
self.assertEqual(expected, InboundService._build_confirm_request_hash(11))
|
|
self.assertNotEqual(expected, InboundService._build_confirm_request_hash(12))
|
|
|
|
|
|
class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
self.user = make_user()
|
|
self.fixed_time = datetime(2026, 7, 26, 10, 30)
|
|
self.scope_patcher = patch.object(
|
|
AccessDao,
|
|
'list_user_warehouse_ids',
|
|
new=AsyncMock(return_value=[2, 5, 7, 8, 11]),
|
|
)
|
|
self.default_scope = self.scope_patcher.start()
|
|
self.addCleanup(self.scope_patcher.stop)
|
|
|
|
async def test_add_validates_and_locks_pallets_before_writing_draft(self) -> None:
|
|
page = InboundCreateModel(warehouseId=7, palletIds=[5, 2])
|
|
inventories = [make_inventory(2), make_inventory(5)]
|
|
pallets = [make_pallet(2), make_pallet(5)]
|
|
actions: list[str] = []
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def access(*_args: Any, **_kwargs: Any) -> None:
|
|
actions.append('access')
|
|
|
|
async def lock_inventories(*_args: Any) -> list[BizPalletInventory]:
|
|
actions.append('inventory-lock')
|
|
return inventories
|
|
|
|
async def lock_pallets(*_args: Any) -> list[BizPallet]:
|
|
actions.append('pallet-lock')
|
|
return pallets
|
|
|
|
async def add_inbound(_db: AsyncSession, inbound: BizInboundOrder) -> BizInboundOrder:
|
|
actions.append('add-order')
|
|
inbound.inbound_id = 11
|
|
captured['order'] = inbound
|
|
return inbound
|
|
|
|
async def replace_items(
|
|
_db: AsyncSession,
|
|
inbound_id: int,
|
|
items: list[BizInboundItem],
|
|
) -> list[BizInboundItem]:
|
|
actions.append('replace-items')
|
|
captured['inbound_id'] = inbound_id
|
|
captured['items'] = items
|
|
return items
|
|
|
|
async def commit() -> None:
|
|
actions.append('commit')
|
|
|
|
self.db.commit.side_effect = commit
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=access)),
|
|
patch.object(
|
|
InventoryDao,
|
|
'get_inventories_for_update',
|
|
new=AsyncMock(side_effect=lock_inventories),
|
|
) as inventory_lock,
|
|
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)) as pallet_lock,
|
|
patch.object(OperationDao, 'add_inbound', new=AsyncMock(side_effect=add_inbound)),
|
|
patch.object(OperationDao, 'replace_inbound_items', new=AsyncMock(side_effect=replace_items)),
|
|
patch.object(inbound_service_module.secrets, 'token_hex', return_value='fixed-random') as token_hex,
|
|
patch.object(inbound_service_module, 'datetime') as service_datetime,
|
|
):
|
|
service_datetime.now.return_value = self.fixed_time
|
|
result = await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
|
|
self.assertEqual(
|
|
[
|
|
'access',
|
|
'inventory-lock',
|
|
'pallet-lock',
|
|
'access',
|
|
'add-order',
|
|
'replace-items',
|
|
'commit',
|
|
],
|
|
actions,
|
|
)
|
|
inventory_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal())
|
|
pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal())
|
|
token_hex.assert_called_once_with(16)
|
|
order = captured['order']
|
|
self.assertEqual('IN-fixed-random', order.inbound_no)
|
|
self.assertLessEqual(len(order.inbound_no), 64)
|
|
self.assertEqual('DRAFT', order.status)
|
|
self.assertEqual('user-7', order.create_by)
|
|
self.assertEqual('user-7', order.update_by)
|
|
self.assertEqual(self.fixed_time, order.create_time)
|
|
self.assertEqual(self.fixed_time, order.update_time)
|
|
self.assertEqual(11, captured['inbound_id'])
|
|
self.assertEqual([2, 5], [item.pallet_id for item in captured['items']])
|
|
self.assertTrue(all(item.create_by == 'user-7' for item in captured['items']))
|
|
self.assertTrue(all(item.create_time == self.fixed_time for item in captured['items']))
|
|
self.assertTrue(result.is_success)
|
|
self.db.rollback.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)
|
|
add_order = AsyncMock()
|
|
replace_items = AsyncMock()
|
|
add_event = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(
|
|
InventoryDao,
|
|
'get_inventories_for_update',
|
|
new=AsyncMock(return_value=[make_inventory(2), invalid_inventory]),
|
|
),
|
|
patch.object(
|
|
InventoryDao,
|
|
'get_pallets_by_ids',
|
|
new=AsyncMock(return_value=[make_pallet(2), make_pallet(5)]),
|
|
),
|
|
patch.object(OperationDao, 'add_inbound', new=add_order),
|
|
patch.object(OperationDao, 'replace_inbound_items', new=replace_items),
|
|
patch.object(InventoryDao, 'add_event', new=add_event),
|
|
self.assertRaises(ServiceException),
|
|
):
|
|
await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
|
|
add_order.assert_not_awaited()
|
|
replace_items.assert_not_awaited()
|
|
add_event.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_add_requires_active_authorized_warehouse(self) -> None:
|
|
page = InboundCreateModel(warehouseId=7, palletIds=[2])
|
|
lock = AsyncMock()
|
|
cases = (
|
|
(None, ServiceException),
|
|
(SimpleNamespace(warehouse_id=7, status='1'), ServiceException),
|
|
)
|
|
for warehouse, exception_type in cases:
|
|
with self.subTest(warehouse=warehouse):
|
|
self.db.reset_mock()
|
|
lock.reset_mock()
|
|
with (
|
|
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=warehouse)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=lock),
|
|
self.assertRaises(exception_type),
|
|
):
|
|
await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
lock.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
self.db.reset_mock()
|
|
lock.reset_mock()
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(
|
|
AccessService,
|
|
'assert_warehouse_access',
|
|
new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该仓库')),
|
|
),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=lock),
|
|
self.assertRaises(PermissionException),
|
|
):
|
|
await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
lock.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_add_checks_warehouse_access_before_revealing_existence(self) -> None:
|
|
page = InboundCreateModel(warehouseId=404, palletIds=[2])
|
|
get_warehouse = AsyncMock(return_value=None)
|
|
access = AsyncMock(
|
|
side_effect=PermissionException(data='', message='无权访问该仓库'),
|
|
)
|
|
pallet_lock = AsyncMock()
|
|
|
|
with (
|
|
patch.object(MasterDao, 'get_warehouse', new=get_warehouse),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=pallet_lock),
|
|
self.assertRaises(Exception) as raised,
|
|
):
|
|
await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
|
|
self.assertIsInstance(raised.exception, PermissionException)
|
|
self.assertEqual('无权访问该仓库', raised.exception.message)
|
|
access.assert_awaited_once_with(self.db, self.user, 404)
|
|
get_warehouse.assert_not_awaited()
|
|
pallet_lock.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_add_rechecks_revoked_mapping_after_pallet_locks_before_writing(self) -> None:
|
|
page = InboundCreateModel(warehouseId=7, palletIds=[2])
|
|
|
|
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
|
|
if kwargs.get('for_update'):
|
|
raise PermissionException(data='', message='无权访问该仓库')
|
|
|
|
add_order = AsyncMock(return_value=make_inbound(pallet_ids=(2,)))
|
|
replace_items = AsyncMock()
|
|
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
|
warehouse_read = AsyncMock(
|
|
return_value=SimpleNamespace(warehouse_id=7, status='0'),
|
|
)
|
|
with (
|
|
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
|
patch.object(MasterDao, 'get_warehouse', new=warehouse_read),
|
|
patch.object(
|
|
InboundService,
|
|
'_lock_and_validate_pallets',
|
|
new=AsyncMock(return_value=([make_inventory(2)], [make_pallet(2)])),
|
|
),
|
|
patch.object(OperationDao, 'add_inbound', new=add_order),
|
|
patch.object(OperationDao, 'replace_inbound_items', new=replace_items),
|
|
self.assertRaises(PermissionException) as raised,
|
|
):
|
|
await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
|
|
self.assertEqual('无权访问该仓库', raised.exception.message)
|
|
self.assertEqual(2, warehouse_read.await_count)
|
|
warehouse_read.assert_awaited_with(self.db, 7, TestBatchScope.formal(), for_update=True)
|
|
self.assertEqual(2, warehouse_mapping.await_count)
|
|
warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True)
|
|
add_order.assert_not_awaited()
|
|
replace_items.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_edit_scopes_original_and_checks_target_access_then_replaces_items(self) -> None:
|
|
page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[5, 2])
|
|
inbound = make_inbound(warehouse_id=7, pallet_ids=(3,))
|
|
lock_order = AsyncMock(return_value=inbound)
|
|
access = AsyncMock()
|
|
update = AsyncMock()
|
|
replace = AsyncMock()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=8, status='0')),
|
|
),
|
|
patch.object(
|
|
InventoryDao,
|
|
'get_inventories_for_update',
|
|
new=AsyncMock(return_value=[make_inventory(2), make_inventory(5)]),
|
|
) as inventory_lock,
|
|
patch.object(
|
|
InventoryDao,
|
|
'get_pallets_by_ids',
|
|
new=AsyncMock(return_value=[make_pallet(2), make_pallet(5)]),
|
|
) as pallet_lock,
|
|
patch.object(OperationDao, 'update_inbound', new=update),
|
|
patch.object(OperationDao, 'replace_inbound_items', new=replace),
|
|
patch.object(inbound_service_module, 'datetime') as service_datetime,
|
|
):
|
|
service_datetime.now.return_value = self.fixed_time
|
|
result = await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
|
|
self.default_scope.assert_awaited_once_with(self.db, 7)
|
|
lock_order.assert_awaited_once_with(
|
|
self.db,
|
|
11,
|
|
TestBatchScope.formal(),
|
|
warehouse_ids=[2, 5, 7, 8, 11],
|
|
)
|
|
self.assertEqual(
|
|
[
|
|
unittest.mock.call(self.db, self.user, 8),
|
|
unittest.mock.call(self.db, self.user, 7, for_update=True),
|
|
unittest.mock.call(self.db, self.user, 8, for_update=True),
|
|
],
|
|
access.await_args_list,
|
|
)
|
|
inventory_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal())
|
|
pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal())
|
|
self.assertEqual(8, inbound.warehouse_id)
|
|
self.assertEqual('user-7', inbound.update_by)
|
|
self.assertEqual(self.fixed_time, inbound.update_time)
|
|
update.assert_awaited_once_with(self.db, inbound)
|
|
replace.assert_awaited_once()
|
|
self.assertEqual([2, 5], [item.pallet_id for item in replace.await_args.args[2]])
|
|
self.db.commit.assert_awaited_once()
|
|
self.assertTrue(result.is_success)
|
|
|
|
async def test_edit_hides_document_when_current_mapping_is_revoked_after_pallet_locks(self) -> None:
|
|
current_warehouse_id = 7
|
|
page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[2])
|
|
inbound = make_inbound(warehouse_id=current_warehouse_id, pallet_ids=(2,))
|
|
|
|
async def warehouse_access(
|
|
_db: AsyncSession,
|
|
_user: CurrentUserModel,
|
|
warehouse_id: int,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
if warehouse_id == current_warehouse_id and kwargs.get('for_update'):
|
|
raise PermissionException(data='', message='无权访问该仓库')
|
|
|
|
update = AsyncMock()
|
|
replace = AsyncMock()
|
|
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(
|
|
side_effect=lambda _db, warehouse_id, _scope, **_kwargs: SimpleNamespace(
|
|
warehouse_id=warehouse_id,
|
|
status='0',
|
|
)
|
|
),
|
|
),
|
|
patch.object(
|
|
InboundService,
|
|
'_lock_and_validate_pallets',
|
|
new=AsyncMock(return_value=([make_inventory(2)], [make_pallet(2)])),
|
|
),
|
|
patch.object(OperationDao, 'update_inbound', new=update),
|
|
patch.object(OperationDao, 'replace_inbound_items', new=replace),
|
|
self.assertRaises(PermissionException) as raised,
|
|
):
|
|
await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
|
|
self.assertEqual('无权访问该入库单', raised.exception.message)
|
|
warehouse_mapping.assert_any_await(self.db, self.user, 7, for_update=True)
|
|
warehouse_mapping.assert_any_await(self.db, self.user, 8, for_update=True)
|
|
update.assert_not_awaited()
|
|
replace.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_edit_rejects_confirmed_document_without_pallet_or_order_write(self) -> None:
|
|
page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[2])
|
|
update = AsyncMock()
|
|
lock_pallets = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
OperationDao,
|
|
'get_inbound_for_update',
|
|
new=AsyncMock(return_value=make_inbound(status='CONFIRMED')),
|
|
),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(MasterDao, 'get_warehouse', new=AsyncMock()),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=lock_pallets),
|
|
patch.object(OperationDao, 'update_inbound', new=update),
|
|
self.assertRaises(ServiceException),
|
|
):
|
|
await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
|
|
lock_pallets.assert_not_awaited()
|
|
update.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_edit_scopes_parent_lock_and_hides_unmatched_document(self) -> None:
|
|
page = InboundUpdateModel(inboundId=404, warehouseId=8, palletIds=[2])
|
|
lock_order = AsyncMock(return_value=None)
|
|
get_warehouse = AsyncMock()
|
|
inventory_lock = AsyncMock()
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope,
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
|
|
patch.object(MasterDao, 'get_warehouse', new=get_warehouse),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
|
|
self.assertRaises(Exception) as raised,
|
|
):
|
|
await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal())
|
|
|
|
self.assertIsInstance(raised.exception, PermissionException)
|
|
self.assertEqual('无权访问该入库单', raised.exception.message)
|
|
scope.assert_awaited_once_with(self.db, 7)
|
|
lock_order.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=[7])
|
|
get_warehouse.assert_not_awaited()
|
|
inventory_lock.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_admin_missing_edit_keeps_precise_not_found(self) -> None:
|
|
page = InboundUpdateModel(inboundId=404, warehouseId=8, palletIds=[2])
|
|
scope = AsyncMock()
|
|
lock_order = AsyncMock(return_value=None)
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await InboundService.edit_inbound_services(self.db, page, make_user(1), TestBatchScope.formal())
|
|
|
|
self.assertEqual('入库单不存在', raised.exception.message)
|
|
scope.assert_not_awaited()
|
|
lock_order.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=None)
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_batch_delete_locks_and_preflights_all_before_ordered_delete(self) -> None:
|
|
lock = AsyncMock()
|
|
delete = AsyncMock()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock),
|
|
patch.object(OperationDao, 'delete_inbound', new=delete),
|
|
self.assertRaises(ServiceWarning),
|
|
):
|
|
await InboundService.delete_inbound_services(self.db, [5, 2], self.user, TestBatchScope.formal())
|
|
|
|
lock.assert_not_awaited()
|
|
delete.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
|
|
async def test_batch_delete_rechecks_all_current_warehouses_before_first_delete(self) -> None:
|
|
lock = AsyncMock()
|
|
delete = AsyncMock()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock),
|
|
patch.object(OperationDao, 'delete_inbound', new=delete),
|
|
self.assertRaises(ServiceWarning),
|
|
):
|
|
await InboundService.delete_inbound_services(self.db, [12, 11], self.user, TestBatchScope.formal())
|
|
|
|
lock.assert_not_awaited()
|
|
delete.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
|
|
async def test_batch_delete_is_atomic_when_later_document_is_confirmed(self) -> None:
|
|
lock = AsyncMock()
|
|
delete = AsyncMock()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock),
|
|
patch.object(OperationDao, 'delete_inbound', new=delete),
|
|
self.assertRaises(ServiceWarning),
|
|
):
|
|
await InboundService.delete_inbound_services(self.db, [5, 2], self.user, TestBatchScope.formal())
|
|
|
|
lock.assert_not_awaited()
|
|
delete.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
|
|
async def test_delete_scopes_parent_lock_and_hides_unmatched_document(self) -> None:
|
|
lock_order = AsyncMock(return_value=None)
|
|
delete = AsyncMock()
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope,
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
|
|
patch.object(OperationDao, 'delete_inbound', new=delete),
|
|
self.assertRaises(ServiceWarning),
|
|
):
|
|
await InboundService.delete_inbound_services(self.db, [404], self.user, TestBatchScope.formal())
|
|
|
|
scope.assert_not_awaited()
|
|
lock_order.assert_not_awaited()
|
|
delete.assert_not_awaited()
|
|
|
|
async def test_admin_missing_delete_keeps_precise_not_found(self) -> None:
|
|
scope = AsyncMock()
|
|
lock_order = AsyncMock(return_value=None)
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
|
|
self.assertRaises(ServiceWarning),
|
|
):
|
|
await InboundService.delete_inbound_services(self.db, [404], make_user(1), TestBatchScope.formal())
|
|
|
|
scope.assert_not_awaited()
|
|
lock_order.assert_not_awaited()
|
|
|
|
async def test_all_draft_mutations_convert_integrity_errors(self) -> None:
|
|
add_page = InboundCreateModel(warehouseId=7, palletIds=[2])
|
|
with (
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(
|
|
InventoryDao,
|
|
'get_inventories_for_update',
|
|
new=AsyncMock(return_value=[make_inventory(2)]),
|
|
),
|
|
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])),
|
|
patch.object(OperationDao, 'add_inbound', new=AsyncMock(side_effect=integrity_error())),
|
|
self.assertRaises(ServiceException),
|
|
):
|
|
await InboundService.add_inbound_services(self.db, add_page, self.user, TestBatchScope.formal())
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
self.db.reset_mock()
|
|
with (
|
|
patch.object(
|
|
OperationDao,
|
|
'get_inbound_for_update',
|
|
new=AsyncMock(return_value=make_inbound(pallet_ids=(2,))),
|
|
),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=integrity_error())),
|
|
self.assertRaises(ServiceWarning),
|
|
):
|
|
await InboundService.delete_inbound_services(self.db, [11], self.user, TestBatchScope.formal())
|
|
|
|
|
|
class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
self.user = make_user()
|
|
|
|
async def test_list_passes_normal_user_scope_into_paginated_dao(self) -> None:
|
|
query = InboundPageQueryModel(pageNum=2, pageSize=5)
|
|
page = PageModel[Any](
|
|
rows=[make_inbound()],
|
|
pageNum=2,
|
|
pageSize=5,
|
|
total=1,
|
|
hasNext=False,
|
|
)
|
|
get_list = AsyncMock(return_value=page)
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 3, 7])) as scope,
|
|
patch.object(OperationDao, 'get_inbound_list', new=get_list),
|
|
):
|
|
actual = await InboundService.get_inbound_list_services(
|
|
self.db, query, self.user, TestBatchScope.formal(), is_page=True
|
|
)
|
|
|
|
scope.assert_awaited_once_with(self.db, 7)
|
|
get_list.assert_awaited_once_with(
|
|
self.db,
|
|
query,
|
|
warehouse_ids=[3, 7],
|
|
scope=TestBatchScope.formal(),
|
|
is_page=True,
|
|
)
|
|
self.assertIsInstance(actual, PageModel)
|
|
self.assertIsInstance(actual.rows[0], InboundModel)
|
|
|
|
async def test_admin_list_uses_none_scope_and_does_not_query_assignments(self) -> None:
|
|
query = InboundPageQueryModel()
|
|
page = PageModel[Any](rows=[], pageNum=1, pageSize=10, total=0, hasNext=False)
|
|
scope = AsyncMock()
|
|
get_list = AsyncMock(return_value=page)
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
|
|
patch.object(OperationDao, 'get_inbound_list', new=get_list),
|
|
):
|
|
await InboundService.get_inbound_list_services(
|
|
self.db, query, make_user(1), TestBatchScope.formal(), is_page=True
|
|
)
|
|
|
|
scope.assert_not_awaited()
|
|
get_list.assert_awaited_once_with(
|
|
self.db,
|
|
query,
|
|
warehouse_ids=None,
|
|
scope=TestBatchScope.formal(),
|
|
is_page=True,
|
|
)
|
|
|
|
async def test_explicit_unauthorized_warehouse_is_403_before_query(self) -> None:
|
|
query = InboundPageQueryModel(warehouseId=9)
|
|
get_list = AsyncMock()
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[3, 7])),
|
|
patch.object(OperationDao, 'get_inbound_list', new=get_list),
|
|
self.assertRaises(PermissionException),
|
|
):
|
|
await InboundService.get_inbound_list_services(
|
|
self.db, query, self.user, TestBatchScope.formal(), is_page=True
|
|
)
|
|
get_list.assert_not_awaited()
|
|
|
|
async def test_user_without_scope_gets_empty_paginated_result_without_total_leak(self) -> None:
|
|
query = InboundPageQueryModel(pageNum=4, pageSize=6)
|
|
empty_page = PageModel[Any](rows=[], pageNum=4, pageSize=6, total=0, hasNext=False)
|
|
get_list = AsyncMock(return_value=empty_page)
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])),
|
|
patch.object(OperationDao, 'get_inbound_list', new=get_list),
|
|
):
|
|
actual = await InboundService.get_inbound_list_services(
|
|
self.db, query, self.user, TestBatchScope.formal(), is_page=True
|
|
)
|
|
|
|
get_list.assert_awaited_once_with(
|
|
self.db,
|
|
query,
|
|
warehouse_ids=[],
|
|
scope=TestBatchScope.formal(),
|
|
is_page=True,
|
|
)
|
|
self.assertEqual([], actual.rows)
|
|
self.assertEqual(0, actual.total)
|
|
self.assertFalse(actual.has_next)
|
|
|
|
async def test_detail_pushes_warehouse_scope_into_parent_query(self) -> None:
|
|
inbound = make_inbound()
|
|
get_detail = AsyncMock(return_value=inbound)
|
|
access = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
AccessDao,
|
|
'list_user_warehouse_ids',
|
|
new=AsyncMock(return_value=[7, 3, 7]),
|
|
) as scope,
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
|
):
|
|
actual = await InboundService.inbound_detail_services(self.db, 11, self.user, TestBatchScope.formal())
|
|
|
|
scope.assert_awaited_once_with(self.db, 7)
|
|
get_detail.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[3, 7])
|
|
access.assert_not_awaited()
|
|
self.assertIsInstance(actual, InboundModel)
|
|
|
|
async def test_normal_user_detail_hides_missing_or_external_document(self) -> None:
|
|
get_detail = AsyncMock(return_value=None)
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
|
self.assertRaises(Exception) as raised,
|
|
):
|
|
await InboundService.inbound_detail_services(self.db, 404, self.user, TestBatchScope.formal())
|
|
|
|
self.assertIsInstance(raised.exception, PermissionException)
|
|
self.assertEqual('无权访问该入库单', raised.exception.message)
|
|
get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=[7])
|
|
|
|
async def test_admin_missing_detail_keeps_precise_not_found(self) -> None:
|
|
scope = AsyncMock()
|
|
get_detail = AsyncMock(return_value=None)
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await InboundService.inbound_detail_services(self.db, 404, make_user(1), TestBatchScope.formal())
|
|
|
|
self.assertEqual('入库单不存在', raised.exception.message)
|
|
scope.assert_not_awaited()
|
|
get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=None)
|
|
|
|
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'),
|
|
]
|
|
with (
|
|
patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)) as list_warehouse,
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[2, 3])),
|
|
):
|
|
actual = await InboundService.get_warehouse_options_services(self.db, self.user, TestBatchScope.formal())
|
|
|
|
query = list_warehouse.await_args.args[1]
|
|
self.assertEqual('0', query.status)
|
|
self.assertEqual(TestBatchScope.formal(), list_warehouse.await_args.args[2])
|
|
self.assertIs(False, list_warehouse.await_args.args[3])
|
|
self.assertEqual([BusinessOptionModel(id=2, code='W2', name='二仓')], actual)
|
|
self.assertEqual({'id', 'code', 'name'}, set(BusinessOptionModel.model_fields))
|
|
|
|
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'),
|
|
]
|
|
scope = AsyncMock()
|
|
with (
|
|
patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)),
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
|
|
):
|
|
actual = await InboundService.get_warehouse_options_services(self.db, make_user(1), TestBatchScope.formal())
|
|
|
|
scope.assert_not_awaited()
|
|
self.assertEqual([2, 5], [option.id for option in actual])
|
|
|
|
async def test_warehouse_options_accept_real_page_util_camel_case_rows(self) -> None:
|
|
warehouses = [
|
|
{
|
|
'warehouseId': 2,
|
|
'warehouseCode': 'W2',
|
|
'warehouseName': '二仓',
|
|
'status': '0',
|
|
}
|
|
]
|
|
with (
|
|
patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)),
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[2])),
|
|
):
|
|
actual = await InboundService.get_warehouse_options_services(self.db, self.user, TestBatchScope.formal())
|
|
|
|
self.assertEqual([BusinessOptionModel(id=2, code='W2', name='二仓')], actual)
|
|
|
|
|
|
class TestInboundScanService(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
self.user = make_user()
|
|
self.query = InboundScanQueryModel(warehouseId=7)
|
|
|
|
async def test_scan_returns_only_unstocked_inbound_action_without_lock_or_write(self) -> None:
|
|
pallet = make_pallet(2)
|
|
inventory = make_inventory(2)
|
|
with (
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access,
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
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,
|
|
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock()) as locking_read,
|
|
patch.object(InventoryDao, 'add_event', new=AsyncMock()) as add_event,
|
|
):
|
|
actual = await InboundService.scan_inbound_pallet_services(
|
|
self.db,
|
|
'persisted-token',
|
|
self.query,
|
|
self.user,
|
|
TestBatchScope.formal(),
|
|
)
|
|
|
|
access.assert_awaited_once_with(self.db, self.user, 7)
|
|
get_pallet.assert_awaited_once_with(self.db, 'persisted-token', TestBatchScope.formal())
|
|
get_inventory.assert_awaited_once_with(self.db, 2, TestBatchScope.formal())
|
|
locking_read.assert_not_awaited()
|
|
add_event.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.assertIsInstance(actual, PalletScanResponseModel)
|
|
self.assertEqual(PalletFlowState.UNSTOCKED, actual.flow_state)
|
|
self.assertEqual(['inbound'], actual.allowed_actions)
|
|
self.assertIsNone(actual.strain_id)
|
|
self.assertIsNone(actual.batch_no)
|
|
|
|
async def test_scan_rejects_every_ineligible_asset_or_inventory_state(self) -> None:
|
|
cases = (
|
|
('disabled-asset', make_pallet(2, asset_status='1'), make_inventory(2)),
|
|
('wrong-state', make_pallet(2), make_inventory(2, flow_state='EMPTY_IN_WAREHOUSE')),
|
|
('warehouse-set', make_pallet(2), make_inventory(2, warehouse_id=7)),
|
|
('site-set', make_pallet(2), make_inventory(2, site_id=8)),
|
|
('line-set', make_pallet(2), make_inventory(2, current_line_id=9)),
|
|
('filling-set', make_pallet(2), make_inventory(2, active_filling_id=10)),
|
|
('remaining', make_pallet(2), make_inventory(2, remaining_packages=1)),
|
|
)
|
|
for label, pallet, inventory in cases:
|
|
with (
|
|
self.subTest(label=label),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
|
|
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)),
|
|
self.assertRaises(ServiceException),
|
|
):
|
|
await InboundService.scan_inbound_pallet_services(
|
|
self.db,
|
|
'persisted-token',
|
|
self.query,
|
|
self.user,
|
|
TestBatchScope.formal(),
|
|
)
|
|
|
|
async def test_scan_rejects_unauthorized_disabled_or_missing_target_warehouse_first(self) -> None:
|
|
get_pallet = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
AccessService,
|
|
'assert_warehouse_access',
|
|
new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该仓库')),
|
|
),
|
|
patch.object(MasterDao, 'get_warehouse', new=AsyncMock()),
|
|
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=get_pallet),
|
|
self.assertRaises(PermissionException),
|
|
):
|
|
await InboundService.scan_inbound_pallet_services(
|
|
self.db, 'token', self.query, self.user, TestBatchScope.formal()
|
|
)
|
|
get_pallet.assert_not_awaited()
|
|
|
|
for warehouse in (None, SimpleNamespace(warehouse_id=7, status='1')):
|
|
with self.subTest(warehouse=warehouse):
|
|
get_pallet.reset_mock()
|
|
with (
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=warehouse)),
|
|
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=get_pallet),
|
|
self.assertRaises(ServiceException),
|
|
):
|
|
await InboundService.scan_inbound_pallet_services(
|
|
self.db, 'token', self.query, self.user, TestBatchScope.formal()
|
|
)
|
|
get_pallet.assert_not_awaited()
|
|
|
|
|
|
class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self) -> None:
|
|
self.db = MagicMock(spec=AsyncSession)
|
|
self.user = make_user()
|
|
self.command = ConfirmCommandModel(idempotencyKey='confirm-key')
|
|
self.fixed_time = datetime(2026, 7, 26, 14, 20)
|
|
self.request_hash = InboundService._build_confirm_request_hash(11)
|
|
self.scope_patcher = patch.object(
|
|
AccessDao,
|
|
'list_user_warehouse_ids',
|
|
new=AsyncMock(return_value=[7]),
|
|
)
|
|
self.default_scope = self.scope_patcher.start()
|
|
self.addCleanup(self.scope_patcher.stop)
|
|
|
|
async def test_confirm_scopes_initial_parent_and_hides_unmatched_document(self) -> None:
|
|
get_detail = AsyncMock(return_value=None)
|
|
get_by_key = AsyncMock()
|
|
lock_order = AsyncMock()
|
|
inventory_lock = AsyncMock()
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope,
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
|
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=get_by_key),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
|
|
self.assertRaises(Exception) as raised,
|
|
):
|
|
await InboundService.confirm_inbound_services(
|
|
self.db, 404, self.command, self.user, TestBatchScope.formal()
|
|
)
|
|
|
|
self.assertIsInstance(raised.exception, PermissionException)
|
|
self.assertEqual('无权访问该入库单', raised.exception.message)
|
|
scope.assert_awaited_once_with(self.db, 7)
|
|
get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=[7])
|
|
get_by_key.assert_not_awaited()
|
|
lock_order.assert_not_awaited()
|
|
inventory_lock.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_admin_missing_confirm_keeps_precise_not_found(self) -> None:
|
|
scope = AsyncMock()
|
|
get_detail = AsyncMock(return_value=None)
|
|
get_by_key = AsyncMock()
|
|
lock_order = AsyncMock()
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
|
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=get_by_key),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
|
|
self.assertRaises(ServiceException) as raised,
|
|
):
|
|
await InboundService.confirm_inbound_services(
|
|
self.db, 404, self.command, make_user(1), TestBatchScope.formal()
|
|
)
|
|
|
|
self.assertEqual('入库单不存在', raised.exception.message)
|
|
scope.assert_not_awaited()
|
|
get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=None)
|
|
get_by_key.assert_not_awaited()
|
|
lock_order.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def _run_successful_confirm(self, *, commit_side_effect: Exception | None = None) -> dict[str, Any]:
|
|
inbound = make_inbound()
|
|
inventories = [make_inventory(2, version=4), make_inventory(5, version=8)]
|
|
pallets = [make_pallet(2), make_pallet(5)]
|
|
actions: list[str] = []
|
|
events: list[BizPalletEvent] = []
|
|
|
|
async def detail(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
|
|
actions.append('detail')
|
|
return inbound
|
|
|
|
async def access(*_args: Any, **_kwargs: Any) -> None:
|
|
actions.append('access')
|
|
|
|
async def by_key(*_args: Any, **_kwargs: Any) -> None:
|
|
actions.append('idempotency-read')
|
|
|
|
async def lock_order(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
|
|
actions.append('order-lock')
|
|
return inbound
|
|
|
|
async def lock_inventories(*_args: Any) -> list[BizPalletInventory]:
|
|
actions.append('inventory-lock')
|
|
return inventories
|
|
|
|
async def lock_pallets(*_args: Any) -> list[BizPallet]:
|
|
actions.append('pallet-lock')
|
|
return pallets
|
|
|
|
async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
|
|
actions.append('warehouse-lock')
|
|
return SimpleNamespace(warehouse_id=7, status='0')
|
|
|
|
def transition(current: PalletFlowState, target: PalletFlowState) -> None:
|
|
actions.append(f'transition-{current.value}-{target.value}')
|
|
|
|
async def update(*_args: Any) -> BizInboundOrder:
|
|
actions.append('update-order')
|
|
return inbound
|
|
|
|
async def add_event(
|
|
_db: AsyncSession,
|
|
inventory: BizPalletInventory,
|
|
event: BizPalletEvent,
|
|
) -> BizPalletEvent:
|
|
actions.append(f'event-{inventory.pallet_id}')
|
|
events.append(event)
|
|
event.event_id = 100 + inventory.pallet_id
|
|
inventory.last_event_id = event.event_id
|
|
return event
|
|
|
|
async def commit() -> None:
|
|
actions.append('commit')
|
|
if commit_side_effect is not None:
|
|
raise commit_side_effect
|
|
|
|
self.db.commit.side_effect = commit
|
|
patches = (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(side_effect=detail)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=access)),
|
|
patch.object(
|
|
OperationDao,
|
|
'get_inbound_by_confirm_idempotency_key',
|
|
new=AsyncMock(side_effect=by_key),
|
|
),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(side_effect=lock_order)),
|
|
patch.object(
|
|
InventoryDao,
|
|
'get_inventories_for_update',
|
|
new=AsyncMock(side_effect=lock_inventories),
|
|
),
|
|
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)),
|
|
patch.object(PalletStateMachine, 'ensure_transition', side_effect=transition),
|
|
patch.object(OperationDao, 'update_inbound', new=AsyncMock(side_effect=update)),
|
|
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
|
|
patch.object(inbound_service_module, 'datetime'),
|
|
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=lock_warehouse)),
|
|
)
|
|
with (
|
|
patches[0] as detail_read,
|
|
patches[1] as access_probe,
|
|
patches[2] as key_read,
|
|
patches[3] as order_lock,
|
|
patches[4] as inventory_lock,
|
|
patches[5] as pallet_lock,
|
|
patches[6] as ensure_transition,
|
|
patches[7],
|
|
patches[8],
|
|
patches[9] as service_datetime,
|
|
patches[10] as warehouse_lock,
|
|
):
|
|
service_datetime.now.return_value = self.fixed_time
|
|
result = await InboundService.confirm_inbound_services(
|
|
self.db, 11, self.command, self.user, TestBatchScope.formal()
|
|
)
|
|
|
|
return {
|
|
'result': result,
|
|
'inbound': inbound,
|
|
'inventories': inventories,
|
|
'events': events,
|
|
'actions': actions,
|
|
'detail_read': detail_read,
|
|
'key_read': key_read,
|
|
'access_probe': access_probe,
|
|
'order_lock': order_lock,
|
|
'inventory_lock': inventory_lock,
|
|
'pallet_lock': pallet_lock,
|
|
'warehouse_lock': warehouse_lock,
|
|
'ensure_transition': ensure_transition,
|
|
}
|
|
|
|
async def test_confirm_uses_complete_lock_order_state_machine_version_and_events(self) -> None:
|
|
captured = await self._run_successful_confirm()
|
|
|
|
self.assertEqual(
|
|
[
|
|
'detail',
|
|
'idempotency-read',
|
|
'order-lock',
|
|
'inventory-lock',
|
|
'pallet-lock',
|
|
'warehouse-lock',
|
|
'access',
|
|
'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE',
|
|
'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE',
|
|
'update-order',
|
|
'event-2',
|
|
'event-5',
|
|
'commit',
|
|
],
|
|
captured['actions'],
|
|
)
|
|
self.default_scope.assert_awaited_once_with(self.db, 7)
|
|
captured['detail_read'].assert_awaited_once_with(
|
|
self.db,
|
|
11,
|
|
TestBatchScope.formal(),
|
|
warehouse_ids=[7],
|
|
)
|
|
captured['key_read'].assert_awaited_once_with(
|
|
self.db,
|
|
'confirm-key',
|
|
TestBatchScope.formal(),
|
|
warehouse_ids=[7],
|
|
)
|
|
captured['order_lock'].assert_awaited_once_with(
|
|
self.db,
|
|
11,
|
|
TestBatchScope.formal(),
|
|
warehouse_ids=[7],
|
|
)
|
|
captured['warehouse_lock'].assert_awaited_once_with(
|
|
self.db,
|
|
7,
|
|
TestBatchScope.formal(),
|
|
for_update=True,
|
|
)
|
|
captured['access_probe'].assert_awaited_once_with(self.db, self.user, 7, for_update=True)
|
|
captured['inventory_lock'].assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal())
|
|
captured['pallet_lock'].assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal())
|
|
captured['ensure_transition'].assert_has_calls(
|
|
[
|
|
unittest.mock.call(PalletFlowState.UNSTOCKED, PalletFlowState.EMPTY_IN_WAREHOUSE),
|
|
unittest.mock.call(PalletFlowState.UNSTOCKED, PalletFlowState.EMPTY_IN_WAREHOUSE),
|
|
]
|
|
)
|
|
inbound = captured['inbound']
|
|
self.assertEqual('CONFIRMED', inbound.status)
|
|
self.assertEqual('confirm-key', inbound.confirm_idempotency_key)
|
|
self.assertEqual(self.request_hash, inbound.confirm_request_hash)
|
|
self.assertEqual(7, inbound.confirmed_by)
|
|
self.assertEqual(self.fixed_time, inbound.confirmed_at)
|
|
self.assertEqual('user-7', inbound.update_by)
|
|
self.assertEqual([5, 9], [inventory.version for inventory in captured['inventories']])
|
|
for inventory in captured['inventories']:
|
|
self.assertEqual('EMPTY_IN_WAREHOUSE', inventory.flow_state)
|
|
self.assertEqual(7, inventory.warehouse_id)
|
|
self.assertIsNone(inventory.site_id)
|
|
self.assertIsNone(inventory.current_line_id)
|
|
self.assertIsNone(inventory.active_filling_id)
|
|
self.assertEqual(0, inventory.remaining_packages)
|
|
self.assertEqual(self.fixed_time, inventory.updated_at)
|
|
self.assertEqual([2, 5], [event.pallet_id for event in captured['events']])
|
|
for event in captured['events']:
|
|
self.assertEqual(PalletEventType.INBOUND_CONFIRMED.value, event.event_type)
|
|
self.assertEqual('INBOUND', event.source_type)
|
|
self.assertEqual(11, event.source_id)
|
|
self.assertEqual('UNSTOCKED', event.before_state)
|
|
self.assertEqual('EMPTY_IN_WAREHOUSE', event.after_state)
|
|
self.assertIsNone(event.before_warehouse_id)
|
|
self.assertIsNone(event.before_site_id)
|
|
self.assertIsNone(event.before_line_id)
|
|
self.assertEqual(7, event.after_warehouse_id)
|
|
self.assertIsNone(event.after_site_id)
|
|
self.assertIsNone(event.after_line_id)
|
|
self.assertEqual(0, event.before_remaining_packages)
|
|
self.assertEqual(0, event.after_remaining_packages)
|
|
self.assertEqual(7, event.operator_id)
|
|
self.assertEqual(self.fixed_time, event.occurred_at)
|
|
self.assertEqual('confirm-key', event.idempotency_key)
|
|
self.assertEqual(self.request_hash, event.request_hash)
|
|
self.db.commit.assert_awaited_once()
|
|
self.db.rollback.assert_not_awaited()
|
|
self.assertTrue(captured['result'].is_success)
|
|
|
|
async def test_confirm_hides_document_when_mapping_is_revoked_after_pallet_locks(self) -> None:
|
|
inbound = make_inbound(pallet_ids=(2,))
|
|
inventory = make_inventory(2)
|
|
|
|
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
|
|
if kwargs.get('for_update'):
|
|
raise PermissionException(data='', message='无权访问该仓库')
|
|
|
|
update = AsyncMock()
|
|
add_event = AsyncMock()
|
|
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
|
patch.object(
|
|
OperationDao,
|
|
'get_inbound_by_confirm_idempotency_key',
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
|
patch.object(
|
|
InboundService,
|
|
'_lock_and_validate_pallets',
|
|
new=AsyncMock(return_value=([inventory], [make_pallet(2)])),
|
|
),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
) as warehouse_lock,
|
|
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
|
patch.object(OperationDao, 'update_inbound', new=update),
|
|
patch.object(InventoryDao, 'add_event', new=add_event),
|
|
self.assertRaises(PermissionException) as raised,
|
|
):
|
|
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal())
|
|
|
|
self.assertEqual('无权访问该入库单', raised.exception.message)
|
|
warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True)
|
|
warehouse_mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True)
|
|
self.assertEqual('DRAFT', inbound.status)
|
|
self.assertEqual('UNSTOCKED', inventory.flow_state)
|
|
update.assert_not_awaited()
|
|
add_event.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_confirm_rejects_incomplete_locked_sets_before_any_mutation(self) -> None:
|
|
inbound = make_inbound()
|
|
inventories = [make_inventory(2)]
|
|
pallets = [make_pallet(2), make_pallet(5)]
|
|
update = AsyncMock()
|
|
add_event = AsyncMock()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=None)),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=inventories)),
|
|
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=pallets)) as pallet_lock,
|
|
patch.object(OperationDao, 'update_inbound', new=update),
|
|
patch.object(InventoryDao, 'add_event', new=add_event),
|
|
self.assertRaises(ServiceException),
|
|
):
|
|
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal())
|
|
|
|
pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal())
|
|
self.assertEqual('DRAFT', inbound.status)
|
|
self.assertEqual('UNSTOCKED', inventories[0].flow_state)
|
|
update.assert_not_awaited()
|
|
add_event.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_confirm_state_machine_failure_rolls_back_before_writes(self) -> None:
|
|
inbound = make_inbound(pallet_ids=(2,))
|
|
inventory = make_inventory(2)
|
|
update = AsyncMock()
|
|
add_event = AsyncMock()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=None)),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])),
|
|
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(PalletStateMachine, 'ensure_transition', side_effect=ValueError('invalid')),
|
|
patch.object(OperationDao, 'update_inbound', new=update),
|
|
patch.object(InventoryDao, 'add_event', new=add_event),
|
|
self.assertRaisesRegex(ValueError, 'invalid'),
|
|
):
|
|
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal())
|
|
|
|
self.assertEqual('DRAFT', inbound.status)
|
|
self.assertEqual('UNSTOCKED', inventory.flow_state)
|
|
update.assert_not_awaited()
|
|
add_event.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_same_key_retry_rechecks_locked_document_and_ends_read_transaction(self) -> None:
|
|
confirmed = make_inbound(
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash=self.request_hash,
|
|
)
|
|
inventory_lock = AsyncMock()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=confirmed)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access,
|
|
patch.object(
|
|
OperationDao,
|
|
'get_inbound_by_confirm_idempotency_key',
|
|
new=AsyncMock(return_value=confirmed),
|
|
),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=confirmed)) as lock,
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
) as warehouse_lock,
|
|
):
|
|
result = await InboundService.confirm_inbound_services(
|
|
self.db, 11, self.command, self.user, TestBatchScope.formal()
|
|
)
|
|
|
|
lock.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7])
|
|
warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True)
|
|
self.assertEqual(
|
|
[
|
|
unittest.mock.call(self.db, self.user, 7),
|
|
unittest.mock.call(self.db, self.user, 7, for_update=True),
|
|
],
|
|
access.await_args_list,
|
|
)
|
|
inventory_lock.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
self.assertTrue(result.is_success)
|
|
|
|
async def test_same_key_retry_uses_exact_warehouse_when_user_can_access_duplicate_keys(self) -> None:
|
|
target = make_inbound(
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash=self.request_hash,
|
|
)
|
|
other_domain = make_inbound(
|
|
inbound_id=12,
|
|
warehouse_id=8,
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash=InboundService._build_confirm_request_hash(12),
|
|
)
|
|
|
|
async def read_key(
|
|
_db: AsyncSession,
|
|
_key: str,
|
|
_scope: TestBatchScope,
|
|
*,
|
|
warehouse_ids: list[int] | None = None,
|
|
) -> BizInboundOrder | None:
|
|
matches = [
|
|
inbound
|
|
for inbound in (target, other_domain)
|
|
if warehouse_ids is None or inbound.warehouse_id in warehouse_ids
|
|
]
|
|
if len(matches) > 1:
|
|
raise MultipleResultsFound('广域幂等键查询返回多条入库单')
|
|
return matches[0] if matches else None
|
|
|
|
get_detail = AsyncMock(return_value=target)
|
|
key_read = AsyncMock(side_effect=read_key)
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
|
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_read),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=target)),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
):
|
|
result = await InboundService.confirm_inbound_services(
|
|
self.db,
|
|
11,
|
|
self.command,
|
|
self.user,
|
|
TestBatchScope.formal(),
|
|
)
|
|
|
|
self.assertTrue(result.is_success)
|
|
get_detail.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7, 8])
|
|
key_read.assert_awaited_once_with(
|
|
self.db,
|
|
'confirm-key',
|
|
TestBatchScope.formal(),
|
|
warehouse_ids=[7],
|
|
)
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_same_key_retry_hides_document_when_current_mapping_is_revoked(self) -> None:
|
|
confirmed = make_inbound(
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash=self.request_hash,
|
|
)
|
|
|
|
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
|
|
if kwargs.get('for_update'):
|
|
raise PermissionException(data='', message='无权访问该仓库')
|
|
|
|
inventory_lock = AsyncMock()
|
|
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=confirmed)),
|
|
patch.object(
|
|
OperationDao,
|
|
'get_inbound_by_confirm_idempotency_key',
|
|
new=AsyncMock(return_value=confirmed),
|
|
),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=confirmed)),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='1')),
|
|
) as warehouse_lock,
|
|
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
|
self.assertRaises(PermissionException) as raised,
|
|
):
|
|
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal())
|
|
|
|
self.assertEqual('无权访问该入库单', raised.exception.message)
|
|
warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True)
|
|
warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True)
|
|
inventory_lock.assert_not_awaited()
|
|
self.db.commit.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_same_key_for_different_inbound_is_rejected_without_lock_or_write(self) -> None:
|
|
target = make_inbound()
|
|
existing = make_inbound(
|
|
inbound_id=12,
|
|
warehouse_id=8,
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash=InboundService._build_confirm_request_hash(12),
|
|
)
|
|
lock = AsyncMock()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=target)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access,
|
|
patch.object(
|
|
OperationDao,
|
|
'get_inbound_by_confirm_idempotency_key',
|
|
new=AsyncMock(return_value=existing),
|
|
),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=lock),
|
|
self.assertRaises(ServiceException),
|
|
):
|
|
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal())
|
|
|
|
access.assert_awaited_once_with(self.db, self.user, 8)
|
|
lock.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_concurrent_status_change_is_rechecked_after_parent_lock(self) -> None:
|
|
initial = make_inbound()
|
|
locked = make_inbound(
|
|
status='CONFIRMED',
|
|
idempotency_key='other-key',
|
|
request_hash=self.request_hash,
|
|
)
|
|
inventory_lock = AsyncMock()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=initial)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=None)),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=locked)),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
|
|
self.assertRaises(ServiceException),
|
|
):
|
|
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal())
|
|
|
|
inventory_lock.assert_not_awaited()
|
|
self.db.rollback.assert_awaited_once()
|
|
|
|
async def test_confirm_uses_locked_current_items_instead_of_initial_snapshot(self) -> None:
|
|
initial = make_inbound(pallet_ids=(2,))
|
|
locked = make_inbound(pallet_ids=(5,))
|
|
inventory = make_inventory(5)
|
|
get_detail = AsyncMock(return_value=initial)
|
|
inventory_lock = AsyncMock(return_value=[inventory])
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=AsyncMock(return_value=None)),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=locked)),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
|
|
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(5)])),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
|
|
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
|
|
):
|
|
result = await InboundService.confirm_inbound_services(
|
|
self.db, 11, self.command, self.user, TestBatchScope.formal()
|
|
)
|
|
|
|
get_detail.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7])
|
|
inventory_lock.assert_awaited_once_with(self.db, [5], TestBatchScope.formal())
|
|
self.assertEqual('EMPTY_IN_WAREHOUSE', inventory.flow_state)
|
|
self.assertTrue(result.is_success)
|
|
|
|
async def test_integrity_conflict_recovers_only_exact_confirmed_result_and_ends_read_transaction(self) -> None:
|
|
inbound = make_inbound(pallet_ids=(2,))
|
|
inventory = make_inventory(2)
|
|
recovered = make_inbound(
|
|
pallet_ids=(2,),
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash=self.request_hash,
|
|
)
|
|
key_reads = AsyncMock(side_effect=[None, recovered])
|
|
self.db.commit.side_effect = integrity_error()
|
|
parent_lock = AsyncMock(side_effect=[inbound, recovered])
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()) as access,
|
|
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_reads),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])),
|
|
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
|
|
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
|
|
):
|
|
result = await InboundService.confirm_inbound_services(
|
|
self.db, 11, self.command, self.user, TestBatchScope.formal()
|
|
)
|
|
|
|
self.assertEqual(2, parent_lock.await_count)
|
|
self.assertEqual(
|
|
[
|
|
unittest.mock.call(self.db, self.user, 7, for_update=True),
|
|
unittest.mock.call(self.db, self.user, 7, for_update=True),
|
|
],
|
|
access.await_args_list,
|
|
)
|
|
self.assertEqual(2, key_reads.await_count)
|
|
key_reads.assert_has_awaits(
|
|
[
|
|
unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]),
|
|
unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]),
|
|
]
|
|
)
|
|
self.assertEqual(2, self.db.rollback.await_count)
|
|
self.assertTrue(result.is_success)
|
|
|
|
async def test_integrity_recovery_uses_exact_warehouse_with_duplicate_visible_keys(self) -> None:
|
|
initial = make_inbound(status='DRAFT')
|
|
recovered = make_inbound(
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash=self.request_hash,
|
|
)
|
|
other_domain = make_inbound(
|
|
inbound_id=12,
|
|
warehouse_id=8,
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash=InboundService._build_confirm_request_hash(12),
|
|
)
|
|
read_count = 0
|
|
|
|
async def read_key(
|
|
_db: AsyncSession,
|
|
_key: str,
|
|
_scope: TestBatchScope,
|
|
*,
|
|
warehouse_ids: list[int] | None = None,
|
|
) -> BizInboundOrder | None:
|
|
nonlocal read_count
|
|
read_count += 1
|
|
if read_count == 1:
|
|
return None
|
|
matches = [
|
|
inbound
|
|
for inbound in (recovered, other_domain)
|
|
if warehouse_ids is None or inbound.warehouse_id in warehouse_ids
|
|
]
|
|
if len(matches) > 1:
|
|
raise MultipleResultsFound('广域幂等键恢复查询返回多条入库单')
|
|
return matches[0] if matches else None
|
|
|
|
warehouse_scope = AsyncMock(side_effect=[[7, 8], [7, 8]])
|
|
key_read = AsyncMock(side_effect=read_key)
|
|
parent_lock = AsyncMock(side_effect=[integrity_error(), recovered])
|
|
with (
|
|
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=initial)),
|
|
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_read),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
):
|
|
result = await InboundService.confirm_inbound_services(
|
|
self.db,
|
|
11,
|
|
self.command,
|
|
self.user,
|
|
TestBatchScope.formal(),
|
|
)
|
|
|
|
self.assertTrue(result.is_success)
|
|
self.assertEqual(2, warehouse_scope.await_count)
|
|
self.assertEqual(2, parent_lock.await_count)
|
|
self.assertEqual(
|
|
[
|
|
unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]),
|
|
unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]),
|
|
],
|
|
key_read.await_args_list,
|
|
)
|
|
self.assertEqual(2, self.db.rollback.await_count)
|
|
self.db.commit.assert_not_awaited()
|
|
|
|
async def test_integrity_recovery_locks_parent_and_rechecks_revoked_current_mapping(self) -> None:
|
|
inbound = make_inbound(pallet_ids=(2,))
|
|
recovered = make_inbound(
|
|
pallet_ids=(2,),
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash=self.request_hash,
|
|
)
|
|
locked_access_count = 0
|
|
recovery_access_number = 2
|
|
|
|
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
|
|
nonlocal locked_access_count
|
|
if not kwargs.get('for_update'):
|
|
return
|
|
locked_access_count += 1
|
|
if locked_access_count == recovery_access_number:
|
|
raise PermissionException(data='', message='无权访问该仓库')
|
|
|
|
self.db.commit.side_effect = integrity_error()
|
|
parent_lock = AsyncMock(side_effect=[inbound, recovered])
|
|
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
|
patch.object(
|
|
OperationDao,
|
|
'get_inbound_by_confirm_idempotency_key',
|
|
new=AsyncMock(side_effect=[None, recovered]),
|
|
),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock),
|
|
patch.object(
|
|
InboundService,
|
|
'_lock_and_validate_pallets',
|
|
new=AsyncMock(return_value=([make_inventory(2)], [make_pallet(2)])),
|
|
),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
) as warehouse_lock,
|
|
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
|
|
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
|
|
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
|
|
self.assertRaises(PermissionException) as raised,
|
|
):
|
|
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal())
|
|
|
|
self.assertEqual('无权访问该入库单', raised.exception.message)
|
|
self.assertEqual(2, parent_lock.await_count)
|
|
parent_lock.assert_awaited_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7])
|
|
self.assertEqual(2, warehouse_lock.await_count)
|
|
self.assertEqual(2, locked_access_count)
|
|
self.assertEqual(2, self.db.rollback.await_count)
|
|
|
|
async def test_integrity_conflict_with_mismatched_recovery_is_stable_failure(self) -> None:
|
|
inbound = make_inbound(pallet_ids=(2,))
|
|
inventory = make_inventory(2)
|
|
recovered = make_inbound(
|
|
pallet_ids=(2,),
|
|
status='CONFIRMED',
|
|
idempotency_key='confirm-key',
|
|
request_hash='different-hash',
|
|
)
|
|
self.db.commit.side_effect = integrity_error()
|
|
with (
|
|
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
|
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
|
patch.object(
|
|
OperationDao,
|
|
'get_inbound_by_confirm_idempotency_key',
|
|
new=AsyncMock(side_effect=[None, recovered]),
|
|
),
|
|
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
|
|
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])),
|
|
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2)])),
|
|
patch.object(
|
|
MasterDao,
|
|
'get_warehouse',
|
|
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
|
|
),
|
|
patch.object(OperationDao, 'update_inbound', new=AsyncMock()),
|
|
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
|
|
self.assertRaises(ServiceException),
|
|
):
|
|
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal())
|
|
|
|
self.assertEqual(2, self.db.rollback.await_count)
|
|
|
|
|
|
class TestInboundLogPermission(unittest.IsolatedAsyncioTestCase):
|
|
async def test_real_inbound_log_wrapper_preserves_permission_code(self) -> None:
|
|
async def receive() -> dict[str, Any]:
|
|
return {
|
|
'type': 'http.request',
|
|
'body': b'{"warehouseId":404,"palletIds":[2]}',
|
|
'more_body': False,
|
|
}
|
|
|
|
request = Request(
|
|
{
|
|
'type': 'http',
|
|
'method': 'POST',
|
|
'scheme': 'http',
|
|
'path': '/business/inbound',
|
|
'raw_path': b'/business/inbound',
|
|
'root_path': '',
|
|
'query_string': b'',
|
|
'headers': [
|
|
(b'content-type', b'application/json'),
|
|
(b'user-agent', b'unittest'),
|
|
],
|
|
'client': ('127.0.0.1', 12345),
|
|
'server': ('testserver', 80),
|
|
},
|
|
receive=receive,
|
|
)
|
|
user = make_user()
|
|
db = MagicMock(spec=AsyncSession)
|
|
service = AsyncMock(
|
|
side_effect=PermissionException(data='', message='无权访问该仓库'),
|
|
)
|
|
enqueue_log = AsyncMock()
|
|
user_token = RequestContext.set_current_user(user)
|
|
try:
|
|
with (
|
|
patch.object(InboundService, 'add_inbound_services', new=service),
|
|
patch(
|
|
'common.annotation.log_annotation.Log._get_oper_location',
|
|
new=AsyncMock(return_value='内网IP'),
|
|
),
|
|
patch.object(LogQueueService, 'enqueue_operation_log', new=enqueue_log),
|
|
patch('common.annotation.log_annotation.logger.exception') as exception_log,
|
|
):
|
|
response = await inbound_controller_module.add_business_inbound(
|
|
request=request,
|
|
add_inbound=InboundCreateModel(warehouseId=404, palletIds=[2]),
|
|
query_db=db,
|
|
current_user=user,
|
|
test_scope=TestBatchScope.formal(),
|
|
)
|
|
finally:
|
|
RequestContext.reset_current_user(user_token)
|
|
|
|
payload = json.loads(response.body)
|
|
self.assertEqual(403, payload['code'])
|
|
self.assertEqual('无权访问该仓库', payload['msg'])
|
|
self.assertFalse(payload['success'])
|
|
service.assert_awaited_once()
|
|
exception_log.assert_not_called()
|
|
enqueue_log.assert_awaited_once()
|
|
|
|
|
|
class TestInboundController(unittest.TestCase):
|
|
@staticmethod
|
|
def _routes() -> dict[tuple[str, str], APIRoute]:
|
|
return {
|
|
(route.path, next(iter(route.methods))): route
|
|
for route in inbound_controller_module.inbound_controller.routes
|
|
if isinstance(route, APIRoute)
|
|
}
|
|
|
|
def test_openapi_has_exact_methods_query_aliases_and_response_models(self) -> None:
|
|
app = FastAPI()
|
|
app.include_router(inbound_controller_module.inbound_controller)
|
|
schema = app.openapi()
|
|
expected = {
|
|
('/business/inbound/list', 'get'),
|
|
('/business/inbound/warehouse-options', 'get'),
|
|
('/business/inbound/scan/{qr_token}', 'get'),
|
|
('/business/inbound/{inbound_id}', 'get'),
|
|
('/business/inbound', 'post'),
|
|
('/business/inbound', 'put'),
|
|
('/business/inbound/{inbound_ids}', 'delete'),
|
|
('/business/inbound/{inbound_id}/confirm', 'put'),
|
|
}
|
|
self.assertEqual(expected, {(path, method) for path, methods in schema['paths'].items() for method in methods})
|
|
scan_parameters = schema['paths']['/business/inbound/scan/{qr_token}']['get']['parameters']
|
|
self.assertIn('warehouseId', {parameter['name'] for parameter in scan_parameters})
|
|
|
|
routes = self._routes()
|
|
self.assertEqual(PageResponseModel[InboundModel], routes[('/business/inbound/list', 'GET')].response_model)
|
|
self.assertEqual(
|
|
DataResponseModel[list[BusinessOptionModel]],
|
|
routes[('/business/inbound/warehouse-options', 'GET')].response_model,
|
|
)
|
|
self.assertEqual(
|
|
DataResponseModel[PalletScanResponseModel],
|
|
routes[('/business/inbound/scan/{qr_token}', 'GET')].response_model,
|
|
)
|
|
self.assertEqual(
|
|
DataResponseModel[InboundModel],
|
|
routes[('/business/inbound/{inbound_id}', 'GET')].response_model,
|
|
)
|
|
for key in (
|
|
('/business/inbound', 'POST'),
|
|
('/business/inbound', 'PUT'),
|
|
('/business/inbound/{inbound_ids}', 'DELETE'),
|
|
('/business/inbound/{inbound_id}/confirm', 'PUT'),
|
|
):
|
|
self.assertEqual(ResponseBaseModel, routes[key].response_model)
|
|
|
|
def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
|
|
paths = [route.path for route in inbound_controller_module.inbound_controller.routes]
|
|
detail_index = paths.index('/business/inbound/{inbound_id}')
|
|
self.assertLess(paths.index('/business/inbound/warehouse-options'), detail_index)
|
|
self.assertLess(paths.index('/business/inbound/scan/{qr_token}'), detail_index)
|
|
|
|
def test_each_route_has_exact_permission_and_six_unique_permission_values(self) -> None:
|
|
expected = {
|
|
('/business/inbound/list', 'GET'): 'business:inbound:list',
|
|
('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query',
|
|
('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query',
|
|
('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query',
|
|
('/business/inbound', 'POST'): 'business:inbound:add',
|
|
('/business/inbound', 'PUT'): 'business:inbound:edit',
|
|
('/business/inbound/{inbound_ids}', 'DELETE'): 'business:inbound:remove',
|
|
('/business/inbound/{inbound_id}/confirm', 'PUT'): 'business:inbound:confirm',
|
|
}
|
|
actual = {}
|
|
for key, route in self._routes().items():
|
|
permissions = [
|
|
dependency.dependency
|
|
for dependency in route.dependencies
|
|
if isinstance(dependency.dependency, CheckUserInterfaceAuth)
|
|
]
|
|
self.assertEqual(1, len(permissions))
|
|
self.assertTrue(any(isinstance(dependency.dependency, PreAuth) for dependency in route.dependencies))
|
|
actual[key] = permissions[0].perm
|
|
self.assertEqual(expected, actual)
|
|
self.assertEqual(
|
|
{
|
|
'business:inbound:list',
|
|
'business:inbound:query',
|
|
'business:inbound:add',
|
|
'business:inbound:edit',
|
|
'business:inbound:remove',
|
|
'business:inbound:confirm',
|
|
},
|
|
set(actual.values()),
|
|
)
|
|
|
|
def test_all_data_scoped_reads_and_writes_inject_current_user_and_writes_have_log(self) -> None:
|
|
routes = self._routes()
|
|
for key, route in routes.items():
|
|
self.assertTrue(
|
|
any(
|
|
dependency.name == 'current_user' and dependency.call == LoginService.get_current_user
|
|
for dependency in route.dependant.dependencies
|
|
),
|
|
key,
|
|
)
|
|
if key[1] in {'POST', 'PUT', 'DELETE'}:
|
|
self.assertTrue(hasattr(route.endpoint, '__wrapped__'), key)
|
|
|
|
def test_controller_does_not_add_scan_or_export_permission(self) -> None:
|
|
source = inspect.getsource(inbound_controller_module)
|
|
self.assertNotIn('business:inbound:scan', source)
|
|
self.assertNotIn('business:inbound:export', source)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|