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

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.context import RequestContext
from common.enums import BusinessType from common.enums import BusinessType
from config.env import AppConfig 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.entity.vo.log_vo import LogininforModel, OperLogModel
from module_admin.service.log_service import LogQueueService from module_admin.service.log_service import LogQueueService
from utils.client_ip_util import ClientIPUtil from utils.client_ip_util import ClientIPUtil
@@ -193,6 +193,8 @@ class Log:
except (LoginException, ServiceWarning) as e: except (LoginException, ServiceWarning) as e:
logger.warning(e.message) logger.warning(e.message)
result = ResponseUtil.failure(data=e.data, msg=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: except ServiceException as e:
logger.error(e.message) logger.error(e.message)
result = ResponseUtil.error(data=e.data, msg=e.message) result = ResponseUtil.error(data=e.data, msg=e.message)
@@ -51,6 +51,15 @@ class OperationDao:
conditions.append(column < end_at) conditions.append(column < end_at)
return conditions 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 @staticmethod
async def _paginate( async def _paginate(
db: AsyncSession, db: AsyncSession,
@@ -98,8 +107,9 @@ class OperationDao:
model: type[Any], model: type[Any],
id_column: InstrumentedAttribute, id_column: InstrumentedAttribute,
record_id: int, record_id: int,
*extra_conditions: ColumnElement[bool],
) -> Any | None: ) -> 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() return result.scalar_one_or_none()
@classmethod @classmethod
@@ -112,8 +122,15 @@ class OperationDao:
item_model: type[Any], item_model: type[Any],
item_order_id_column: InstrumentedAttribute, item_order_id_column: InstrumentedAttribute,
item_id_column: InstrumentedAttribute, item_id_column: InstrumentedAttribute,
*order_conditions: ColumnElement[bool],
) -> Any | None: ) -> 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: if order is None:
return None return None
@@ -185,12 +202,14 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
inbound_id: int, inbound_id: int,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder | None: ) -> BizInboundOrder | None:
inbound = await cls._get_for_update( inbound = await cls._get_for_update(
db, db,
BizInboundOrder, BizInboundOrder,
BizInboundOrder.inbound_id, BizInboundOrder.inbound_id,
inbound_id, inbound_id,
*cls._inbound_scope_conditions(warehouse_ids),
) )
if inbound is None: if inbound is None:
return None return None
@@ -336,12 +355,7 @@ class OperationDao:
is_page: bool = True, is_page: bool = True,
warehouse_ids: list[int] | None = None, warehouse_ids: list[int] | None = None,
) -> PageModel | list[dict[str, Any]]: ) -> PageModel | list[dict[str, Any]]:
conditions = [] conditions = cls._inbound_scope_conditions(warehouse_ids)
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()
)
if query_object.inbound_no: if query_object.inbound_no:
conditions.append(BizInboundOrder.inbound_no.like(f'%{query_object.inbound_no}%')) conditions.append(BizInboundOrder.inbound_no.like(f'%{query_object.inbound_no}%'))
if query_object.warehouse_id: if query_object.warehouse_id:
@@ -363,6 +377,7 @@ class OperationDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
inbound_id: int, inbound_id: int,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder | None: ) -> BizInboundOrder | None:
return await cls._get_order_detail( return await cls._get_order_detail(
db, db,
@@ -372,6 +387,7 @@ class OperationDao:
BizInboundItem, BizInboundItem,
BizInboundItem.inbound_id, BizInboundItem.inbound_id,
BizInboundItem.inbound_item_id, BizInboundItem.inbound_item_id,
*cls._inbound_scope_conditions(warehouse_ids),
) )
@classmethod @classmethod
@@ -2,7 +2,7 @@ import hashlib
import json import json
import secrets import secrets
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, NoReturn
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -81,18 +81,27 @@ class InboundService:
user_id, _ = cls._current_identity(current_user) user_id, _ = cls._current_identity(current_user)
return sorted(set(await AccessDao.list_user_warehouse_ids(query_db, user_id))) 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 @staticmethod
async def _validate_active_warehouse( async def _validate_active_warehouse(
query_db: AsyncSession, query_db: AsyncSession,
warehouse_id: int, warehouse_id: int,
current_user: CurrentUserModel, current_user: CurrentUserModel,
) -> Any: ) -> Any:
await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id)
warehouse = await MasterDao.get_warehouse(query_db, warehouse_id) warehouse = await MasterDao.get_warehouse(query_db, warehouse_id)
if warehouse is None: if warehouse is None:
raise ServiceException(message='目标仓库不存在') raise ServiceException(message='目标仓库不存在')
if warehouse.status != '0': if warehouse.status != '0':
raise ServiceException(message='目标仓库已停用') raise ServiceException(message='目标仓库已停用')
await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id)
return warehouse return warehouse
@staticmethod @staticmethod
@@ -188,10 +197,14 @@ class InboundService:
inbound_id: int, inbound_id: int,
current_user: CurrentUserModel, current_user: CurrentUserModel,
) -> InboundModel: ) -> 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: if inbound is None:
raise ServiceException(message='入库单不存在') cls._raise_inbound_not_accessible(current_user)
await AccessService.assert_warehouse_access(query_db, current_user, inbound.warehouse_id)
return InboundModel.model_validate(inbound) return InboundModel.model_validate(inbound)
@classmethod @classmethod
@@ -318,14 +331,14 @@ class InboundService:
) -> CrudResponseModel: ) -> CrudResponseModel:
try: try:
_, user_name = cls._current_identity(current_user) _, user_name = cls._current_identity(current_user)
inbound = await OperationDao.get_inbound_for_update(query_db, page_object.inbound_id) warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if inbound is None: inbound = await OperationDao.get_inbound_for_update(
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
query_db, query_db,
current_user, page_object.inbound_id,
inbound.warehouse_id, warehouse_ids=warehouse_ids,
) )
if inbound is None:
cls._raise_inbound_not_accessible(current_user)
if inbound.status != DocumentStatus.DRAFT.value: if inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认入库单不允许编辑') raise ServiceException(message='已确认入库单不允许编辑')
await cls._validate_active_warehouse( await cls._validate_active_warehouse(
@@ -369,15 +382,15 @@ class InboundService:
raise ServiceException(message='入库单ID不能重复') raise ServiceException(message='入库单ID不能重复')
ordered_ids = sorted(inbound_ids) ordered_ids = sorted(inbound_ids)
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
for inbound_id in ordered_ids: for inbound_id in ordered_ids:
inbound = await OperationDao.get_inbound_for_update(query_db, inbound_id) inbound = await OperationDao.get_inbound_for_update(
if inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
query_db, query_db,
current_user, inbound_id,
inbound.warehouse_id, warehouse_ids=warehouse_ids,
) )
if inbound is None:
cls._raise_inbound_not_accessible(current_user)
if inbound.status != DocumentStatus.DRAFT.value: if inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认入库单不允许删除') raise ServiceException(message='已确认入库单不允许删除')
@@ -448,14 +461,14 @@ class InboundService:
) -> CrudResponseModel: ) -> CrudResponseModel:
try: try:
user_id, user_name = cls._current_identity(current_user) user_id, user_name = cls._current_identity(current_user)
inbound = await OperationDao.get_inbound_by_id(query_db, inbound_id) warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if inbound is None: inbound = await OperationDao.get_inbound_by_id(
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
query_db, query_db,
current_user, inbound_id,
inbound.warehouse_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) request_hash = cls._build_confirm_request_hash(inbound_id)
idempotency_key = page_object.idempotency_key idempotency_key = page_object.idempotency_key
@@ -477,14 +490,13 @@ class InboundService:
): ):
raise ServiceException(message='确认入库失败,幂等键已被其他请求使用') raise ServiceException(message='确认入库失败,幂等键已被其他请求使用')
locked_inbound = await OperationDao.get_inbound_for_update(query_db, inbound_id) locked_inbound = await OperationDao.get_inbound_for_update(
if locked_inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
query_db, query_db,
current_user, inbound_id,
locked_inbound.warehouse_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 locked_inbound.status == DocumentStatus.CONFIRMED.value:
if cls._is_exact_confirmed( if cls._is_exact_confirmed(
locked_inbound, locked_inbound,
@@ -7,13 +7,14 @@ from types import SimpleNamespace
from typing import Any, get_type_hints from typing import Any, get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.interface_auth import CheckUserInterfaceAuth from common.aspect.interface_auth import CheckUserInterfaceAuth
from common.aspect.pre_auth import PreAuth from common.aspect.pre_auth import PreAuth
from common.context import RequestContext
from common.vo import ( from common.vo import (
CrudResponseModel, CrudResponseModel,
DataResponseModel, DataResponseModel,
@@ -23,6 +24,7 @@ from common.vo import (
) )
from exceptions.exception import PermissionException, ServiceException from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel 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_admin.service.login_service import LoginService
from module_business.controller import inbound_controller as inbound_controller_module from module_business.controller import inbound_controller as inbound_controller_module
from module_business.dao.access_dao import AccessDao from module_business.dao.access_dao import AccessDao
@@ -161,6 +163,13 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
self.db = MagicMock(spec=AsyncSession) self.db = MagicMock(spec=AsyncSession)
self.user = make_user() self.user = make_user()
self.fixed_time = datetime(2026, 7, 26, 10, 30) 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: async def test_add_validates_and_locks_pallets_before_writing_draft(self) -> None:
page = InboundCreateModel(warehouseId=7, palletIds=[5, 2]) page = InboundCreateModel(warehouseId=7, palletIds=[5, 2])
@@ -320,14 +329,38 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
lock.assert_not_awaited() lock.assert_not_awaited()
self.db.rollback.assert_awaited_once() 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]) page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[5, 2])
inbound = make_inbound(warehouse_id=7, pallet_ids=(3,)) inbound = make_inbound(warehouse_id=7, pallet_ids=(3,))
lock_order = AsyncMock(return_value=inbound)
access = AsyncMock() access = AsyncMock()
update = AsyncMock() update = AsyncMock()
replace = AsyncMock() replace = AsyncMock()
with ( 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(AccessService, 'assert_warehouse_access', new=access),
patch.object( patch.object(
MasterDao, MasterDao,
@@ -351,10 +384,13 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
service_datetime.now.return_value = self.fixed_time service_datetime.now.return_value = self.fixed_time
result = await InboundService.edit_inbound_services(self.db, page, self.user) result = await InboundService.edit_inbound_services(self.db, page, self.user)
self.assertEqual( self.default_scope.assert_awaited_once_with(self.db, 7)
[unittest.mock.call(self.db, self.user, 7), unittest.mock.call(self.db, self.user, 8)], lock_order.assert_awaited_once_with(
access.await_args_list, 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]) inventory_lock.assert_awaited_once_with(self.db, [2, 5])
pallet_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) self.assertEqual(8, inbound.warehouse_id)
@@ -388,16 +424,56 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
update.assert_not_awaited() update.assert_not_awaited()
self.db.rollback.assert_awaited_once() 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: async def test_batch_delete_locks_and_preflights_all_before_ordered_delete(self) -> None:
actions: list[str] = [] 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}') actions.append(f'lock-{inbound_id}')
return make_inbound(inbound_id=inbound_id, warehouse_id=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: async def delete(_db: AsyncSession, inbound_id: int) -> None:
actions.append(f'delete-{inbound_id}') actions.append(f'delete-{inbound_id}')
@@ -405,17 +481,20 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
actions.append('commit') actions.append('commit')
self.db.commit.side_effect = commit self.db.commit.side_effect = commit
access = AsyncMock()
with ( with (
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(side_effect=lock)), 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)), patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=delete)),
): ):
result = await InboundService.delete_inbound_services(self.db, [5, 2], self.user) result = await InboundService.delete_inbound_services(self.db, [5, 2], self.user)
self.assertEqual( 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, actions,
) )
self.default_scope.assert_awaited_once_with(self.db, 7)
access.assert_not_awaited()
self.assertTrue(result.is_success) self.assertTrue(result.is_success)
self.db.rollback.assert_not_awaited() self.db.rollback.assert_not_awaited()
@@ -429,7 +508,9 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
patch.object( patch.object(
OperationDao, OperationDao,
'get_inbound_for_update', '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(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(OperationDao, 'delete_inbound', new=delete), patch.object(OperationDao, 'delete_inbound', new=delete),
@@ -441,6 +522,39 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once() 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: async def test_all_draft_mutations_convert_integrity_errors(self) -> None:
add_page = InboundCreateModel(warehouseId=7, palletIds=[2]) add_page = InboundCreateModel(warehouseId=7, palletIds=[2])
with ( with (
@@ -543,28 +657,52 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(0, actual.total) self.assertEqual(0, actual.total)
self.assertFalse(actual.has_next) 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() inbound = make_inbound()
get_detail = AsyncMock(return_value=inbound)
access = AsyncMock() access = AsyncMock()
with ( 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), patch.object(AccessService, 'assert_warehouse_access', new=access),
): ):
actual = await InboundService.inbound_detail_services(self.db, 11, self.user) 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) self.assertIsInstance(actual, InboundModel)
async def test_missing_detail_fails_stably_without_access_probe(self) -> None: async def test_normal_user_detail_hides_missing_or_external_document(self) -> None:
access = AsyncMock() get_detail = AsyncMock(return_value=None)
with ( with (
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=None)), patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessService, 'assert_warehouse_access', new=access), patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
self.assertRaises(ServiceException) as raised, self.assertRaises(Exception) as raised,
): ):
await InboundService.inbound_detail_services(self.db, 404, self.user) 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) 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: async def test_warehouse_options_filter_disabled_and_unauthorized_records(self) -> None:
warehouses = [ warehouses = [
@@ -722,6 +860,58 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.command = ConfirmCommandModel(idempotencyKey='confirm-key') self.command = ConfirmCommandModel(idempotencyKey='confirm-key')
self.fixed_time = datetime(2026, 7, 26, 14, 20) self.fixed_time = datetime(2026, 7, 26, 14, 20)
self.request_hash = InboundService._build_confirm_request_hash(11) 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]: async def _run_successful_confirm(self, *, commit_side_effect: Exception | None = None) -> dict[str, Any]:
inbound = make_inbound() inbound = make_inbound()
@@ -730,7 +920,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
actions: list[str] = [] actions: list[str] = []
events: list[BizPalletEvent] = [] events: list[BizPalletEvent] = []
async def detail(*_args: Any) -> BizInboundOrder: async def detail(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
actions.append('detail') actions.append('detail')
return inbound return inbound
@@ -740,7 +930,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
async def by_key(*_args: Any) -> None: async def by_key(*_args: Any) -> None:
actions.append('idempotency-read') actions.append('idempotency-read')
async def lock_order(*_args: Any) -> BizInboundOrder: async def lock_order(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
actions.append('order-lock') actions.append('order-lock')
return inbound return inbound
@@ -797,10 +987,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(inbound_service_module, 'datetime'), patch.object(inbound_service_module, 'datetime'),
) )
with ( with (
patches[0], patches[0] as detail_read,
patches[1], patches[1] as access_probe,
patches[2], patches[2],
patches[3], patches[3] as order_lock,
patches[4] as inventory_lock, patches[4] as inventory_lock,
patches[5] as pallet_lock, patches[5] as pallet_lock,
patches[6] as ensure_transition, patches[6] as ensure_transition,
@@ -817,6 +1007,9 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
'inventories': inventories, 'inventories': inventories,
'events': events, 'events': events,
'actions': actions, 'actions': actions,
'detail_read': detail_read,
'access_probe': access_probe,
'order_lock': order_lock,
'inventory_lock': inventory_lock, 'inventory_lock': inventory_lock,
'pallet_lock': pallet_lock, 'pallet_lock': pallet_lock,
'ensure_transition': ensure_transition, 'ensure_transition': ensure_transition,
@@ -828,10 +1021,8 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.assertEqual( self.assertEqual(
[ [
'detail', 'detail',
'access',
'idempotency-read', 'idempotency-read',
'order-lock', 'order-lock',
'access',
'inventory-lock', 'inventory-lock',
'pallet-lock', 'pallet-lock',
'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE', 'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE',
@@ -843,6 +1034,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
], ],
captured['actions'], 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['inventory_lock'].assert_awaited_once_with(self.db, [2, 5])
captured['pallet_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( 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) result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
lock.assert_awaited_once_with(self.db, 11) lock.assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
self.assertEqual(3, access.await_count) access.assert_awaited_once_with(self.db, self.user, 7)
inventory_lock.assert_not_awaited() inventory_lock.assert_not_awaited()
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once() 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) 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() lock.assert_not_awaited()
self.db.rollback.assert_awaited_once() 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) 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]) inventory_lock.assert_awaited_once_with(self.db, [5])
self.assertEqual('EMPTY_IN_WAREHOUSE', inventory.flow_state) self.assertEqual('EMPTY_IN_WAREHOUSE', inventory.flow_state)
self.assertTrue(result.is_success) 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) 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, key_reads.await_count)
self.assertEqual(2, self.db.rollback.await_count) self.assertEqual(2, self.db.rollback.await_count)
self.assertTrue(result.is_success) self.assertTrue(result.is_success)
@@ -1098,6 +1293,68 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(2, self.db.rollback.await_count) 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): class TestInboundController(unittest.TestCase):
@staticmethod @staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]: 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: def test_operation_dao_critical_parameter_names_and_defaults_are_stable(self) -> None:
expected = { 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_shipment_for_update': ('db', 'shipment_id'),
'get_return_for_update': ('db', 'return_id'), 'get_return_for_update': ('db', 'return_id'),
'get_active_filling_for_update': ('db', 'filling_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_usage_by_idempotency_key': ('db', 'idempotency_key'),
'get_return_by_request_idempotency_key': ('db', 'idempotency_key'), 'get_return_by_request_idempotency_key': ('db', 'idempotency_key'),
'get_return_by_confirm_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'), 'add_inbound': ('db', 'inbound'),
'update_inbound': ('db', 'inbound'), 'update_inbound': ('db', 'inbound'),
'delete_inbound': ('db', 'inbound_id'), 'delete_inbound': ('db', 'inbound_id'),
@@ -164,6 +164,14 @@ class TestDaoPublicContract(unittest.TestCase):
self.assertIs(True, inbound_list_signature.parameters['is_page'].default) self.assertIs(True, inbound_list_signature.parameters['is_page'].default)
self.assertIsNone(inbound_list_signature.parameters['warehouse_ids'].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 ( for method_name in (
'get_filling_list', 'get_filling_list',
'get_shipment_list', 'get_shipment_list',
@@ -446,6 +454,30 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
) )
self.assertIn('FOR UPDATE', item_sql) 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: async def test_idempotency_queries_use_the_exact_declared_column(self) -> None:
cases = ( cases = (
( (
@@ -753,6 +785,29 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
self.db.execute.assert_awaited_once() 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: async def test_simple_details_target_primary_keys(self) -> None:
cases = ( cases = (
(OperationDao.get_filling_by_id, BizFillingRecord, 'filling_id', 14), (OperationDao.get_filling_by_id, BizFillingRecord, 'filling_id', 14),