修复:收紧入库数据范围校验

This commit is contained in:
焦龙言
2026-07-26 16:14:27 +08:00
parent 6f22cf0282
commit 2e616ab6e0
5 changed files with 416 additions and 74 deletions
@@ -19,7 +19,7 @@ from user_agents import parse
from common.context import RequestContext
from common.enums import BusinessType
from config.env import AppConfig
from exceptions.exception import LoginException, ServiceException, ServiceWarning
from exceptions.exception import LoginException, PermissionException, ServiceException, ServiceWarning
from module_admin.entity.vo.log_vo import LogininforModel, OperLogModel
from module_admin.service.log_service import LogQueueService
from utils.client_ip_util import ClientIPUtil
@@ -193,6 +193,8 @@ class Log:
except (LoginException, ServiceWarning) as e:
logger.warning(e.message)
result = ResponseUtil.failure(data=e.data, msg=e.message)
except PermissionException as e:
result = ResponseUtil.forbidden(data=e.data, msg=e.message)
except ServiceException as e:
logger.error(e.message)
result = ResponseUtil.error(data=e.data, msg=e.message)
@@ -51,6 +51,15 @@ class OperationDao:
conditions.append(column < end_at)
return conditions
@staticmethod
def _inbound_scope_conditions(
warehouse_ids: list[int] | None,
) -> list[ColumnElement[bool]]:
if warehouse_ids is None:
return []
scoped_warehouse_ids = sorted(set(warehouse_ids))
return [BizInboundOrder.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()]
@staticmethod
async def _paginate(
db: AsyncSession,
@@ -98,8 +107,9 @@ class OperationDao:
model: type[Any],
id_column: InstrumentedAttribute,
record_id: int,
*extra_conditions: ColumnElement[bool],
) -> Any | None:
result = await db.execute(select(model).where(id_column == record_id))
result = await db.execute(select(model).where(id_column == record_id, *extra_conditions))
return result.scalar_one_or_none()
@classmethod
@@ -112,8 +122,15 @@ class OperationDao:
item_model: type[Any],
item_order_id_column: InstrumentedAttribute,
item_id_column: InstrumentedAttribute,
*order_conditions: ColumnElement[bool],
) -> Any | None:
order = await cls._get_by_id(db, order_model, order_id_column, order_id)
order = await cls._get_by_id(
db,
order_model,
order_id_column,
order_id,
*order_conditions,
)
if order is None:
return None
@@ -185,12 +202,14 @@ class OperationDao:
cls,
db: AsyncSession,
inbound_id: int,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder | None:
inbound = await cls._get_for_update(
db,
BizInboundOrder,
BizInboundOrder.inbound_id,
inbound_id,
*cls._inbound_scope_conditions(warehouse_ids),
)
if inbound is None:
return None
@@ -336,12 +355,7 @@ class OperationDao:
is_page: bool = True,
warehouse_ids: list[int] | None = None,
) -> PageModel | list[dict[str, Any]]:
conditions = []
if warehouse_ids is not None:
scoped_warehouse_ids = sorted(set(warehouse_ids))
conditions.append(
BizInboundOrder.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()
)
conditions = cls._inbound_scope_conditions(warehouse_ids)
if query_object.inbound_no:
conditions.append(BizInboundOrder.inbound_no.like(f'%{query_object.inbound_no}%'))
if query_object.warehouse_id:
@@ -363,6 +377,7 @@ class OperationDao:
cls,
db: AsyncSession,
inbound_id: int,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder | None:
return await cls._get_order_detail(
db,
@@ -372,6 +387,7 @@ class OperationDao:
BizInboundItem,
BizInboundItem.inbound_id,
BizInboundItem.inbound_item_id,
*cls._inbound_scope_conditions(warehouse_ids),
)
@classmethod
@@ -2,7 +2,7 @@ import hashlib
import json
import secrets
from datetime import datetime
from typing import Any
from typing import Any, NoReturn
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -81,18 +81,27 @@ class InboundService:
user_id, _ = cls._current_identity(current_user)
return sorted(set(await AccessDao.list_user_warehouse_ids(query_db, user_id)))
@classmethod
def _raise_inbound_not_accessible(
cls,
current_user: CurrentUserModel,
) -> NoReturn:
if cls._is_admin(current_user):
raise ServiceException(message='入库单不存在')
raise PermissionException(data='', message='无权访问该入库单')
@staticmethod
async def _validate_active_warehouse(
query_db: AsyncSession,
warehouse_id: int,
current_user: CurrentUserModel,
) -> Any:
await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id)
warehouse = await MasterDao.get_warehouse(query_db, warehouse_id)
if warehouse is None:
raise ServiceException(message='目标仓库不存在')
if warehouse.status != '0':
raise ServiceException(message='目标仓库已停用')
await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id)
return warehouse
@staticmethod
@@ -188,10 +197,14 @@ class InboundService:
inbound_id: int,
current_user: CurrentUserModel,
) -> InboundModel:
inbound = await OperationDao.get_inbound_by_id(query_db, inbound_id)
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
inbound = await OperationDao.get_inbound_by_id(
query_db,
inbound_id,
warehouse_ids=warehouse_ids,
)
if inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(query_db, current_user, inbound.warehouse_id)
cls._raise_inbound_not_accessible(current_user)
return InboundModel.model_validate(inbound)
@classmethod
@@ -318,14 +331,14 @@ class InboundService:
) -> CrudResponseModel:
try:
_, user_name = cls._current_identity(current_user)
inbound = await OperationDao.get_inbound_for_update(query_db, page_object.inbound_id)
if inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
inbound = await OperationDao.get_inbound_for_update(
query_db,
current_user,
inbound.warehouse_id,
page_object.inbound_id,
warehouse_ids=warehouse_ids,
)
if inbound is None:
cls._raise_inbound_not_accessible(current_user)
if inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认入库单不允许编辑')
await cls._validate_active_warehouse(
@@ -369,15 +382,15 @@ class InboundService:
raise ServiceException(message='入库单ID不能重复')
ordered_ids = sorted(inbound_ids)
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
for inbound_id in ordered_ids:
inbound = await OperationDao.get_inbound_for_update(query_db, inbound_id)
if inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
inbound = await OperationDao.get_inbound_for_update(
query_db,
current_user,
inbound.warehouse_id,
inbound_id,
warehouse_ids=warehouse_ids,
)
if inbound is None:
cls._raise_inbound_not_accessible(current_user)
if inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认入库单不允许删除')
@@ -448,14 +461,14 @@ class InboundService:
) -> CrudResponseModel:
try:
user_id, user_name = cls._current_identity(current_user)
inbound = await OperationDao.get_inbound_by_id(query_db, inbound_id)
if inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
inbound = await OperationDao.get_inbound_by_id(
query_db,
current_user,
inbound.warehouse_id,
inbound_id,
warehouse_ids=warehouse_ids,
)
if inbound is None:
cls._raise_inbound_not_accessible(current_user)
request_hash = cls._build_confirm_request_hash(inbound_id)
idempotency_key = page_object.idempotency_key
@@ -477,14 +490,13 @@ class InboundService:
):
raise ServiceException(message='确认入库失败,幂等键已被其他请求使用')
locked_inbound = await OperationDao.get_inbound_for_update(query_db, inbound_id)
if locked_inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
locked_inbound = await OperationDao.get_inbound_for_update(
query_db,
current_user,
locked_inbound.warehouse_id,
inbound_id,
warehouse_ids=warehouse_ids,
)
if locked_inbound is None:
cls._raise_inbound_not_accessible(current_user)
if locked_inbound.status == DocumentStatus.CONFIRMED.value:
if cls._is_exact_confirmed(
locked_inbound,
@@ -7,13 +7,14 @@ from types import SimpleNamespace
from typing import Any, get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.routing import APIRoute
from sqlalchemy.exc import IntegrityError
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,
@@ -23,6 +24,7 @@ from common.vo import (
)
from exceptions.exception import PermissionException, ServiceException
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
@@ -161,6 +163,13 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
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])
@@ -320,14 +329,38 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_edit_checks_original_and_target_access_then_replaces_items(self) -> None:
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)
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_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=AsyncMock(return_value=inbound)),
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
patch.object(AccessService, 'assert_warehouse_access', new=access),
patch.object(
MasterDao,
@@ -351,10 +384,13 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
service_datetime.now.return_value = self.fixed_time
result = await InboundService.edit_inbound_services(self.db, page, self.user)
self.assertEqual(
[unittest.mock.call(self.db, self.user, 7), unittest.mock.call(self.db, self.user, 8)],
access.await_args_list,
self.default_scope.assert_awaited_once_with(self.db, 7)
lock_order.assert_awaited_once_with(
self.db,
11,
warehouse_ids=[2, 5, 7, 8, 11],
)
access.assert_awaited_once_with(self.db, self.user, 8)
inventory_lock.assert_awaited_once_with(self.db, [2, 5])
pallet_lock.assert_awaited_once_with(self.db, [2, 5])
self.assertEqual(8, inbound.warehouse_id)
@@ -388,16 +424,56 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
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)
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, 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))
self.assertEqual('入库单不存在', raised.exception.message)
scope.assert_not_awaited()
lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=None)
self.db.rollback.assert_awaited_once()
async def test_batch_delete_locks_and_preflights_all_before_ordered_delete(self) -> None:
actions: list[str] = []
async def lock(_db: AsyncSession, inbound_id: int) -> BizInboundOrder:
async def lock(
_db: AsyncSession,
inbound_id: int,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder:
self.assertEqual([2, 5, 7, 8, 11], warehouse_ids)
actions.append(f'lock-{inbound_id}')
return make_inbound(inbound_id=inbound_id, warehouse_id=inbound_id)
async def access(_db: AsyncSession, _user: CurrentUserModel, warehouse_id: int) -> None:
actions.append(f'access-{warehouse_id}')
async def delete(_db: AsyncSession, inbound_id: int) -> None:
actions.append(f'delete-{inbound_id}')
@@ -405,17 +481,20 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
actions.append('commit')
self.db.commit.side_effect = commit
access = AsyncMock()
with (
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(side_effect=lock)),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=access)),
patch.object(AccessService, 'assert_warehouse_access', new=access),
patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=delete)),
):
result = await InboundService.delete_inbound_services(self.db, [5, 2], self.user)
self.assertEqual(
['lock-2', 'access-2', 'lock-5', 'access-5', 'delete-2', 'delete-5', 'commit'],
['lock-2', 'lock-5', 'delete-2', 'delete-5', 'commit'],
actions,
)
self.default_scope.assert_awaited_once_with(self.db, 7)
access.assert_not_awaited()
self.assertTrue(result.is_success)
self.db.rollback.assert_not_awaited()
@@ -429,7 +508,9 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
patch.object(
OperationDao,
'get_inbound_for_update',
new=AsyncMock(side_effect=lambda _db, inbound_id: records[inbound_id]),
new=AsyncMock(
side_effect=lambda _db, inbound_id, warehouse_ids=None: records[inbound_id],
),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(OperationDao, 'delete_inbound', new=delete),
@@ -441,6 +522,39 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
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(Exception) as raised,
):
await InboundService.delete_inbound_services(self.db, [404], self.user)
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, warehouse_ids=[7])
delete.assert_not_awaited()
self.db.rollback.assert_awaited_once()
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(ServiceException) as raised,
):
await InboundService.delete_inbound_services(self.db, [404], make_user(1))
self.assertEqual('入库单不存在', raised.exception.message)
scope.assert_not_awaited()
lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=None)
self.db.rollback.assert_awaited_once()
async def test_all_draft_mutations_convert_integrity_errors(self) -> None:
add_page = InboundCreateModel(warehouseId=7, palletIds=[2])
with (
@@ -543,28 +657,52 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(0, actual.total)
self.assertFalse(actual.has_next)
async def test_detail_loads_then_checks_warehouse_scope(self) -> None:
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(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
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)
access.assert_awaited_once_with(self.db, self.user, 7)
scope.assert_awaited_once_with(self.db, 7)
get_detail.assert_awaited_once_with(self.db, 11, warehouse_ids=[3, 7])
access.assert_not_awaited()
self.assertIsInstance(actual, InboundModel)
async def test_missing_detail_fails_stably_without_access_probe(self) -> None:
access = AsyncMock()
async def test_normal_user_detail_hides_missing_or_external_document(self) -> None:
get_detail = AsyncMock(return_value=None)
with (
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=None)),
patch.object(AccessService, 'assert_warehouse_access', new=access),
self.assertRaises(ServiceException) as raised,
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)
self.assertIsInstance(raised.exception, PermissionException)
self.assertEqual('无权访问该入库单', raised.exception.message)
get_detail.assert_awaited_once_with(self.db, 404, 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))
self.assertEqual('入库单不存在', raised.exception.message)
access.assert_not_awaited()
scope.assert_not_awaited()
get_detail.assert_awaited_once_with(self.db, 404, warehouse_ids=None)
async def test_warehouse_options_filter_disabled_and_unauthorized_records(self) -> None:
warehouses = [
@@ -722,6 +860,58 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
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)
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, 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))
self.assertEqual('入库单不存在', raised.exception.message)
scope.assert_not_awaited()
get_detail.assert_awaited_once_with(self.db, 404, 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()
@@ -730,7 +920,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
actions: list[str] = []
events: list[BizPalletEvent] = []
async def detail(*_args: Any) -> BizInboundOrder:
async def detail(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
actions.append('detail')
return inbound
@@ -740,7 +930,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
async def by_key(*_args: Any) -> None:
actions.append('idempotency-read')
async def lock_order(*_args: Any) -> BizInboundOrder:
async def lock_order(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
actions.append('order-lock')
return inbound
@@ -797,10 +987,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(inbound_service_module, 'datetime'),
)
with (
patches[0],
patches[1],
patches[0] as detail_read,
patches[1] as access_probe,
patches[2],
patches[3],
patches[3] as order_lock,
patches[4] as inventory_lock,
patches[5] as pallet_lock,
patches[6] as ensure_transition,
@@ -817,6 +1007,9 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
'inventories': inventories,
'events': events,
'actions': actions,
'detail_read': detail_read,
'access_probe': access_probe,
'order_lock': order_lock,
'inventory_lock': inventory_lock,
'pallet_lock': pallet_lock,
'ensure_transition': ensure_transition,
@@ -828,10 +1021,8 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
[
'detail',
'access',
'idempotency-read',
'order-lock',
'access',
'inventory-lock',
'pallet-lock',
'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE',
@@ -843,6 +1034,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
],
captured['actions'],
)
self.default_scope.assert_awaited_once_with(self.db, 7)
captured['detail_read'].assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
captured['order_lock'].assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
captured['access_probe'].assert_not_awaited()
captured['inventory_lock'].assert_awaited_once_with(self.db, [2, 5])
captured['pallet_lock'].assert_awaited_once_with(self.db, [2, 5])
captured['ensure_transition'].assert_has_calls(
@@ -962,8 +1157,8 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
):
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
lock.assert_awaited_once_with(self.db, 11)
self.assertEqual(3, access.await_count)
lock.assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
access.assert_awaited_once_with(self.db, self.user, 7)
inventory_lock.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
@@ -992,7 +1187,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
):
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
self.assertEqual(2, access.await_count)
access.assert_awaited_once_with(self.db, self.user, 8)
lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
@@ -1035,7 +1230,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
):
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
get_detail.assert_awaited_once_with(self.db, 11)
get_detail.assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
inventory_lock.assert_awaited_once_with(self.db, [5])
self.assertEqual('EMPTY_IN_WAREHOUSE', inventory.flow_state)
self.assertTrue(result.is_success)
@@ -1063,7 +1258,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
):
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
self.assertEqual(3, access.await_count)
access.assert_awaited_once_with(self.db, self.user, 7)
self.assertEqual(2, key_reads.await_count)
self.assertEqual(2, self.db.rollback.await_count)
self.assertTrue(result.is_success)
@@ -1098,6 +1293,68 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
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,
)
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]:
@@ -121,7 +121,7 @@ class TestDaoPublicContract(unittest.TestCase):
def test_operation_dao_critical_parameter_names_and_defaults_are_stable(self) -> None:
expected = {
'get_inbound_for_update': ('db', 'inbound_id'),
'get_inbound_for_update': ('db', 'inbound_id', 'warehouse_ids'),
'get_shipment_for_update': ('db', 'shipment_id'),
'get_return_for_update': ('db', 'return_id'),
'get_active_filling_for_update': ('db', 'filling_id'),
@@ -131,7 +131,7 @@ class TestDaoPublicContract(unittest.TestCase):
'get_usage_by_idempotency_key': ('db', 'idempotency_key'),
'get_return_by_request_idempotency_key': ('db', 'idempotency_key'),
'get_return_by_confirm_idempotency_key': ('db', 'idempotency_key'),
'get_inbound_by_id': ('db', 'inbound_id'),
'get_inbound_by_id': ('db', 'inbound_id', 'warehouse_ids'),
'add_inbound': ('db', 'inbound'),
'update_inbound': ('db', 'inbound'),
'delete_inbound': ('db', 'inbound_id'),
@@ -164,6 +164,14 @@ class TestDaoPublicContract(unittest.TestCase):
self.assertIs(True, inbound_list_signature.parameters['is_page'].default)
self.assertIsNone(inbound_list_signature.parameters['warehouse_ids'].default)
for method_name in ('get_inbound_for_update', 'get_inbound_by_id'):
with self.subTest(method=method_name):
signature = inspect.signature(getattr(OperationDao, method_name))
warehouse_ids = signature.parameters.get('warehouse_ids')
self.assertIsNotNone(warehouse_ids)
if warehouse_ids is not None:
self.assertIsNone(warehouse_ids.default)
for method_name in (
'get_filling_list',
'get_shipment_list',
@@ -446,6 +454,30 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
)
self.assertIn('FOR UPDATE', item_sql)
async def test_inbound_for_update_applies_scope_before_locking_items(self) -> None:
self.assertIn(
'warehouse_ids',
inspect.signature(OperationDao.get_inbound_for_update).parameters,
)
parent_result = MagicMock()
parent_result.scalar_one_or_none.return_value = None
self.db.execute.return_value = parent_result
actual = await OperationDao.get_inbound_for_update(
self.db,
inbound_id=11,
warehouse_ids=[7, 3, 7],
)
self.assertIsNone(actual)
self.db.execute.assert_awaited_once()
parent_statement = self.db.execute.await_args.args[0]
for dialect in (mysql.dialect(), postgresql.dialect()):
with self.subTest(dialect=dialect.name):
sql = compile_sql(parent_statement, dialect)
self.assertIn('biz_inbound_order.warehouse_id IN (3, 7)', sql)
self.assertIn('FOR UPDATE', sql)
async def test_idempotency_queries_use_the_exact_declared_column(self) -> None:
cases = (
(
@@ -753,6 +785,29 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
self.db.execute.assert_awaited_once()
async def test_inbound_detail_empty_scope_is_false_and_skips_items(self) -> None:
self.assertIn(
'warehouse_ids',
inspect.signature(OperationDao.get_inbound_by_id).parameters,
)
parent_result = MagicMock()
parent_result.scalar_one_or_none.return_value = None
self.db.execute.return_value = parent_result
actual = await OperationDao.get_inbound_by_id(
self.db,
inbound_id=11,
warehouse_ids=[],
)
self.assertIsNone(actual)
self.db.execute.assert_awaited_once()
parent_statement = self.db.execute.await_args.args[0]
mysql_sql = compile_sql(parent_statement, mysql.dialect())
postgresql_sql = compile_sql(parent_statement, postgresql.dialect())
self.assertIn('false = 1', mysql_sql)
self.assertIn('false', postgresql_sql)
async def test_simple_details_target_primary_keys(self) -> None:
cases = (
(OperationDao.get_filling_by_id, BizFillingRecord, 'filling_id', 14),