修复:收紧入库数据范围校验
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user