Files
ShiErFeng/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py
T

1468 lines
66 KiB
Python

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