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

2808 lines
118 KiB
Python

import hashlib
import importlib.util
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
from fastapi.routing import APIRoute
from sqlalchemy.dialects import postgresql
from sqlalchemy.exc import IntegrityError, MultipleResultsFound
from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.interface_auth import CheckUserInterfaceAuth
from common.aspect.pre_auth import PreAuth
from common.vo import (
CrudResponseModel,
DataResponseModel,
PageModel,
PageResponseModel,
ResponseBaseModel,
)
from exceptions.exception import PermissionException, ServiceException, ServiceWarning
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
from module_admin.service.login_service import LoginService
from module_business.controller import shipment_controller as shipment_controller_module
from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao
from module_business.domain.enums import PalletEventType, PalletFlowState
from module_business.domain.pallet_state_machine import PalletStateMachine
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizWarehouse
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizPalletEvent,
BizPalletInventory,
BizShipmentItem,
BizShipmentOrder,
)
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
ConfirmCommandModel,
PalletScanResponseModel,
ShipmentCreateModel,
ShipmentModel,
ShipmentPageQueryModel,
ShipmentScanQueryModel,
ShipmentUpdateModel,
)
from module_business.service import shipment_service as shipment_service_module
from module_business.service.access_service import AccessService
from module_business.service.shipment_service import ShipmentService
from module_business.service.test_batch_access_service import TestBatchAccessService
from tests.module_business.test_batch_fixtures import make_test_scope
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 integrity_error() -> IntegrityError:
return IntegrityError('statement', {}, RuntimeError('constraint'))
def make_pallet(
pallet_id: int,
*,
capacity: int = 20,
asset_status: str = '0',
test_batch_id: int | None = None,
) -> BizPallet:
return BizPallet(
pallet_id=pallet_id,
pallet_code=f'P-{pallet_id:03d}',
qr_token=f'qr-{pallet_id}',
capacity_packages=capacity,
asset_status=asset_status,
test_batch_id=test_batch_id,
)
def make_inventory(
pallet_id: int,
*,
warehouse_id: int = 7,
site_id: int | None = None,
current_line_id: int | None = None,
active_filling_id: int | None = None,
remaining_packages: int = 8,
flow_state: str = 'FILLED_IN_WAREHOUSE',
version: int = 3,
test_batch_id: int | None = None,
) -> BizPalletInventory:
return BizPalletInventory(
pallet_id=pallet_id,
warehouse_id=warehouse_id,
site_id=site_id,
current_line_id=current_line_id,
active_filling_id=active_filling_id if active_filling_id is not None else 100 + pallet_id,
remaining_packages=remaining_packages,
flow_state=flow_state,
version=version,
test_batch_id=test_batch_id,
)
def make_filling(
pallet_id: int,
*,
warehouse_id: int = 7,
test_batch_id: int | None = None,
) -> BizFillingRecord:
return BizFillingRecord(
filling_id=100 + pallet_id,
filling_no=f'FI-{pallet_id}',
pallet_id=pallet_id,
warehouse_id=warehouse_id,
strain_id=3,
strain_code_snapshot='S-03',
strain_name_snapshot='菌种三',
batch_no='B-202607',
package_spec='袋装',
initial_packages=20,
active_slot='ACTIVE',
idempotency_key=f'fill-{pallet_id}',
request_hash='f' * 64,
operator_id=7,
operated_at=SimpleNamespace(),
test_batch_id=test_batch_id,
)
def make_shipment(
shipment_id: int = 12,
*,
source_warehouse_id: int = 7,
target_site_id: int = 9,
pallet_ids: tuple[int, ...] = (5, 2),
status: str = 'DRAFT',
idempotency_key: str | None = None,
request_hash: str | None = None,
test_batch_id: int | None = None,
) -> BizShipmentOrder:
shipment = BizShipmentOrder(
shipment_id=shipment_id,
shipment_no=f'SH-{shipment_id}',
source_warehouse_id=source_warehouse_id,
target_site_id=target_site_id,
status=status,
confirm_idempotency_key=idempotency_key,
confirm_request_hash=request_hash,
test_batch_id=test_batch_id,
)
shipment.items = [
BizShipmentItem(
shipment_item_id=index,
shipment_id=shipment_id,
pallet_id=pallet_id,
test_batch_id=test_batch_id,
)
for index, pallet_id in enumerate(pallet_ids, start=1)
]
return shipment
class ShipmentServiceBootstrapTest(unittest.TestCase):
def test_public_business_methods_require_explicit_test_scope(self) -> None:
for method_name in (
'get_shipment_list_services',
'shipment_detail_services',
'get_warehouse_options_services',
'get_site_options_services',
'scan_shipment_pallet_services',
'add_shipment_services',
'edit_shipment_services',
'delete_shipment_services',
'confirm_shipment_services',
):
with self.subTest(method=method_name):
self.assertIn('test_scope', inspect.signature(getattr(ShipmentService, method_name)).parameters)
def test_shipment_service_module_exists(self) -> None:
spec = importlib.util.find_spec('module_business.service.shipment_service')
self.assertIsNotNone(spec, 'ShipmentService 尚未实现')
def test_confirm_signature_and_types_are_exact(self) -> None:
self.assertTrue(
hasattr(ShipmentService, 'confirm_shipment_services'),
'confirm_shipment_services 尚未实现',
)
signature = inspect.signature(ShipmentService.confirm_shipment_services)
self.assertEqual(
('query_db', 'shipment_id', 'page_object', 'current_user', 'test_scope'),
tuple(signature.parameters),
)
hints = get_type_hints(ShipmentService.confirm_shipment_services)
self.assertIs(AsyncSession, hints['query_db'])
self.assertIs(int, hints['shipment_id'])
self.assertIs(ConfirmCommandModel, hints['page_object'])
self.assertIs(CurrentUserModel, hints['current_user'])
self.assertIs(CrudResponseModel, hints['return'])
def test_confirm_hash_is_canonical_and_only_contains_shipment_id(self) -> None:
self.assertTrue(
hasattr(ShipmentService, '_build_confirm_request_hash'),
'_build_confirm_request_hash 尚未实现',
)
expected = hashlib.sha256(
json.dumps(
{'shipment_id': 12},
sort_keys=True,
separators=(',', ':'),
).encode()
).hexdigest()
self.assertEqual(expected, ShipmentService._build_confirm_request_hash(12))
self.assertNotEqual(expected, ShipmentService._build_confirm_request_hash(13))
def test_service_exposes_complete_shipment_workflow(self) -> None:
expected = {
'get_shipment_list_services',
'shipment_detail_services',
'get_warehouse_options_services',
'get_site_options_services',
'scan_shipment_pallet_services',
'add_shipment_services',
'edit_shipment_services',
'delete_shipment_services',
'confirm_shipment_services',
}
self.assertEqual(
expected,
{name for name in expected if hasattr(ShipmentService, name)},
)
class ShipmentInfrastructureContractTest(unittest.TestCase):
def test_shipment_dao_reads_accept_both_data_scopes(self) -> None:
expected = {
OperationDao.get_shipment_list: (
'db',
'query_object',
'warehouse_ids',
'site_ids',
'scope',
'is_page',
),
OperationDao.get_shipment_by_id: (
'db',
'shipment_id',
'scope',
'warehouse_ids',
'site_ids',
),
OperationDao.get_shipment_for_update: (
'db',
'shipment_id',
'scope',
'warehouse_ids',
'site_ids',
),
OperationDao.get_shipment_by_confirm_idempotency_key: (
'db',
'idempotency_key',
'scope',
'warehouse_ids',
'site_ids',
),
}
for method, parameter_names in expected.items():
with self.subTest(method=method.__name__):
self.assertEqual(parameter_names, tuple(inspect.signature(method).parameters))
def test_site_access_supports_optional_current_read(self) -> None:
for method in (AccessDao.user_has_site, AccessService.assert_site_access):
with self.subTest(method=method.__qualname__):
parameter = inspect.signature(method).parameters.get('for_update')
self.assertIsNotNone(parameter)
self.assertIs(inspect.Parameter.KEYWORD_ONLY, parameter.kind)
self.assertIs(False, parameter.default)
class ShipmentDetailDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_draft_detail_loads_only_current_shippable_remaining_quantity_with_one_left_join(self) -> None:
db = MagicMock(spec=AsyncSession)
shipment = make_shipment(pallet_ids=(5, 2, 8))
order_result = MagicMock()
order_result.scalar_one_or_none.return_value = shipment
item_result = MagicMock()
item_result.all.return_value = [
(shipment.items[0], 13),
(shipment.items[1], 6),
(shipment.items[2], None),
]
db.execute.side_effect = [order_result, item_result]
actual = await OperationDao.get_shipment_by_id(
db,
12,
warehouse_ids=[7],
site_ids=[9],
scope=TestBatchScope.formal(),
)
self.assertIs(shipment, actual)
self.assertEqual(2, db.execute.await_count)
self.assertEqual([13, 6, None], [item.remaining_packages for item in shipment.items])
self.assertEqual([None, None, None], [item.package_qty for item in shipment.items])
item_statement = db.execute.await_args_list[1].args[0]
item_sql = ' '.join(
str(
item_statement.compile(
dialect=postgresql.dialect(),
compile_kwargs={'literal_binds': True},
)
).split()
)
self.assertIn(
'LEFT OUTER JOIN biz_pallet_inventory '
'ON biz_pallet_inventory.pallet_id = biz_shipment_item.pallet_id '
'AND biz_pallet_inventory.warehouse_id = 7 '
"AND biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE' "
'AND biz_pallet_inventory.site_id IS NULL '
'AND biz_pallet_inventory.current_line_id IS NULL '
'AND biz_pallet_inventory.active_filling_id IS NOT NULL '
'AND biz_pallet_inventory.remaining_packages > 0',
item_sql,
)
self.assertIn('WHERE biz_shipment_item.shipment_id = 12', item_sql)
self.assertIn('ORDER BY biz_shipment_item.shipment_item_id', item_sql)
async def test_confirmed_detail_preserves_snapshot_and_does_not_read_current_inventory(self) -> None:
db = MagicMock(spec=AsyncSession)
shipment = make_shipment(status='CONFIRMED', pallet_ids=(5, 2))
shipment.items[0].active_filling_id = 105
shipment.items[0].package_qty = 13
shipment.items[1].active_filling_id = 102
shipment.items[1].package_qty = 6
order_result = MagicMock()
order_result.scalar_one_or_none.return_value = shipment
item_result = MagicMock()
item_result.all.return_value = [
(shipment.items[0], 1),
(shipment.items[1], 2),
]
item_result.scalars.return_value.all.return_value = shipment.items
db.execute.side_effect = [order_result, item_result]
actual = await OperationDao.get_shipment_by_id(
db,
12,
warehouse_ids=[7],
site_ids=[9],
scope=TestBatchScope.formal(),
)
self.assertIs(shipment, actual)
self.assertEqual(2, db.execute.await_count)
self.assertEqual([13, 6], [item.package_qty for item in shipment.items])
self.assertEqual([None, None], [item.remaining_packages for item in shipment.items])
item_statement = db.execute.await_args_list[1].args[0]
item_sql = ' '.join(
str(
item_statement.compile(
dialect=postgresql.dialect(),
compile_kwargs={'literal_binds': True},
)
).split()
)
self.assertNotIn('biz_pallet_inventory', item_sql)
self.assertIn('WHERE biz_shipment_item.shipment_id = 12', item_sql)
self.assertIn('ORDER BY biz_shipment_item.shipment_item_id', item_sql)
class ShipmentControllerBootstrapTest(unittest.TestCase):
def test_shipment_controller_module_exists(self) -> None:
spec = importlib.util.find_spec('module_business.controller.shipment_controller')
self.assertIsNotNone(spec, 'shipment_controller 尚未实现')
class ShipmentControllerContractTest(unittest.TestCase):
@staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]:
return {
(route.path, next(iter(route.methods))): route
for route in shipment_controller_module.shipment_controller.routes
if isinstance(route, APIRoute)
}
def test_openapi_has_nine_operations_aliases_and_response_models(self) -> None:
app = FastAPI()
app.include_router(shipment_controller_module.shipment_controller)
schema = app.openapi()
expected = {
('/business/shipment/list', 'get'),
('/business/shipment/warehouse-options', 'get'),
('/business/shipment/site-options', 'get'),
('/business/shipment/scan/{qr_token}', 'get'),
('/business/shipment/{shipment_id}', 'get'),
('/business/shipment', 'post'),
('/business/shipment', 'put'),
('/business/shipment/{shipment_ids}', 'delete'),
('/business/shipment/{shipment_id}/confirm', 'put'),
}
self.assertEqual(expected, {(path, method) for path, methods in schema['paths'].items() for method in methods})
scan_parameters = schema['paths']['/business/shipment/scan/{qr_token}']['get']['parameters']
self.assertEqual(
{'qr_token', 'sourceWarehouseId', 'targetSiteId'},
{parameter['name'] for parameter in scan_parameters},
)
routes = self._routes()
self.assertEqual(PageResponseModel[ShipmentModel], routes[('/business/shipment/list', 'GET')].response_model)
for path in ('warehouse-options', 'site-options'):
self.assertEqual(
DataResponseModel[list[BusinessOptionModel]],
routes[(f'/business/shipment/{path}', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[PalletScanResponseModel],
routes[('/business/shipment/scan/{qr_token}', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[ShipmentModel],
routes[('/business/shipment/{shipment_id}', 'GET')].response_model,
)
for key in (
('/business/shipment', 'POST'),
('/business/shipment', 'PUT'),
('/business/shipment/{shipment_ids}', 'DELETE'),
('/business/shipment/{shipment_id}/confirm', 'PUT'),
):
self.assertEqual(ResponseBaseModel, routes[key].response_model)
def test_static_options_and_scan_precede_dynamic_detail(self) -> None:
paths = [route.path for route in shipment_controller_module.shipment_controller.routes]
detail_index = paths.index('/business/shipment/{shipment_id}')
for static_path in (
'/business/shipment/warehouse-options',
'/business/shipment/site-options',
'/business/shipment/scan/{qr_token}',
):
self.assertLess(paths.index(static_path), detail_index)
def test_routes_have_exact_permissions_current_user_and_write_logs(self) -> None:
expected = {
('/business/shipment/list', 'GET'): 'business:shipment:list',
('/business/shipment/warehouse-options', 'GET'): 'business:shipment:query',
('/business/shipment/site-options', 'GET'): 'business:shipment:query',
('/business/shipment/scan/{qr_token}', 'GET'): 'business:shipment:query',
('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query',
('/business/shipment', 'POST'): 'business:shipment:add',
('/business/shipment', 'PUT'): 'business:shipment:edit',
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:remove',
('/business/shipment/{shipment_id}/confirm', 'PUT'): 'business:shipment: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), key)
self.assertTrue(any(isinstance(dependency.dependency, PreAuth) for dependency in route.dependencies))
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)
actual[key] = permissions[0].perm
self.assertEqual(expected, actual)
self.assertEqual(6, len(set(actual.values())))
def test_controller_defines_no_scan_or_export_permission(self) -> None:
source = inspect.getsource(shipment_controller_module)
self.assertNotIn('business:shipment:scan', source)
self.assertNotIn('business:shipment:export', source)
class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.user = make_user()
async def test_empty_either_scope_returns_empty_page_without_global_query(self) -> None:
query = ShipmentPageQueryModel(pageNum=2, pageSize=7)
get_list = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[])),
patch.object(OperationDao, 'get_shipment_list', new=get_list),
):
try:
result = await ShipmentService.get_shipment_list_services(
self.db,
query,
self.user,
TestBatchScope.formal(),
is_page=True,
)
except NotImplementedError:
self.fail('双范围空集处理尚未实现')
self.assertIsInstance(result, PageModel)
self.assertEqual([], result.rows)
self.assertEqual(0, result.total)
self.assertEqual(2, result.page_num)
self.assertEqual(7, result.page_size)
self.assertFalse(result.has_next)
get_list.assert_not_awaited()
async def test_list_and_detail_push_both_scopes_before_reading(self) -> None:
shipment = make_shipment()
page = PageModel(rows=[shipment], pageNum=1, pageSize=10, total=1, hasNext=False)
get_list = AsyncMock(return_value=page)
get_detail = AsyncMock(return_value=shipment)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 9])),
patch.object(OperationDao, 'get_shipment_list', new=get_list),
patch.object(OperationDao, 'get_shipment_by_id', new=get_detail),
):
listed = await ShipmentService.get_shipment_list_services(
self.db,
ShipmentPageQueryModel(),
self.user,
TestBatchScope.formal(),
)
try:
detail = await ShipmentService.shipment_detail_services(self.db, 12, self.user, TestBatchScope.formal())
except NotImplementedError:
self.fail('双范围单据详情尚未实现')
self.assertEqual(1, listed.total)
self.assertEqual(12, detail.shipment_id)
get_list.assert_awaited_once_with(
self.db,
unittest.mock.ANY,
warehouse_ids=[7],
site_ids=[9],
scope=TestBatchScope.formal(),
is_page=True,
)
get_detail.assert_awaited_once_with(
self.db,
12,
TestBatchScope.formal(),
warehouse_ids=[7],
site_ids=[9],
)
async def test_options_only_return_enabled_assigned_warehouse_and_site(self) -> None:
warehouses = [
BizWarehouse(warehouse_id=7, warehouse_code='W-7', warehouse_name='七号仓', status='0'),
BizWarehouse(warehouse_id=8, warehouse_code='W-8', warehouse_name='八号仓', status='0'),
BizWarehouse(warehouse_id=10, warehouse_code='W-10', warehouse_name='停用仓', status='1'),
]
sites = [
BizCustomerSite(site_id=9, customer_id=1, site_code='S-9', site_name='九号现场', status='0'),
BizCustomerSite(site_id=11, customer_id=1, site_code='S-11', site_name='十一号现场', status='0'),
BizCustomerSite(site_id=12, customer_id=1, site_code='S-12', site_name='停用现场', status='1'),
]
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)),
patch.object(MasterDao, 'list_site', new=AsyncMock(return_value=sites)),
):
try:
warehouse_options = await ShipmentService.get_warehouse_options_services(
self.db, self.user, TestBatchScope.formal()
)
site_options = await ShipmentService.get_site_options_services(
self.db, self.user, TestBatchScope.formal()
)
except NotImplementedError:
self.fail('发货仓库/现场选项尚未实现')
self.assertEqual([(7, 'W-7', '七号仓')], [(item.id, item.code, item.name) for item in warehouse_options])
self.assertEqual([(9, 'S-9', '九号现场')], [(item.id, item.code, item.name) for item in site_options])
async def test_scan_is_read_only_and_returns_backend_remaining_packages(self) -> None:
query = ShipmentScanQueryModel(sourceWarehouseId=7, targetSiteId=9)
pallet = make_pallet(2)
inventory = make_inventory(2, remaining_packages=8)
filling = make_filling(2, warehouse_id=3)
lock_inventory = AsyncMock()
lock_pallets = AsyncMock()
lock_filling = AsyncMock()
get_filling = AsyncMock(return_value=filling)
with (
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
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)),
patch.object(OperationDao, 'get_filling_by_id', new=get_filling),
patch.object(InventoryDao, 'get_inventories_for_update', new=lock_inventory),
patch.object(InventoryDao, 'get_pallets_by_ids', new=lock_pallets),
patch.object(OperationDao, 'get_active_filling_for_update', new=lock_filling),
):
try:
result = await ShipmentService.scan_shipment_pallet_services(
self.db,
'qr-2',
query,
self.user,
TestBatchScope.formal(),
)
except NotImplementedError:
self.fail('整托发货扫码尚未实现')
self.assertEqual(2, result.pallet_id)
self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE, result.flow_state)
self.assertEqual(8, result.remaining_packages)
self.assertEqual(['shipment'], result.allowed_actions)
get_filling.assert_awaited_once_with(
self.db,
inventory.active_filling_id,
TestBatchScope.formal(),
)
lock_inventory.assert_not_awaited()
lock_pallets.assert_not_awaited()
lock_filling.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
async def test_explicit_filter_outside_either_scope_is_rejected_before_dao(self) -> None:
cases = (
(ShipmentPageQueryModel(sourceWarehouseId=8), '无权访问该仓库'),
(ShipmentPageQueryModel(targetSiteId=10), '无权访问该客户现场'),
)
for query, expected_message in cases:
with self.subTest(expected_message=expected_message):
get_list = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_list', new=get_list),
self.assertRaises(PermissionException) as raised,
):
await ShipmentService.get_shipment_list_services(self.db, query, self.user, TestBatchScope.formal())
self.assertEqual(expected_message, raised.exception.message)
get_list.assert_not_awaited()
async def test_missing_detail_is_hidden_for_user_but_precise_for_admin(self) -> None:
get_detail = AsyncMock(return_value=None)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=get_detail),
self.assertRaises(PermissionException) as user_raised,
):
await ShipmentService.shipment_detail_services(self.db, 404, self.user, TestBatchScope.formal())
self.assertEqual('无权访问该发货单', user_raised.exception.message)
get_detail.assert_awaited_once_with(
self.db,
404,
TestBatchScope.formal(),
warehouse_ids=[7],
site_ids=[9],
)
get_detail.reset_mock()
with (
patch.object(OperationDao, 'get_shipment_by_id', new=get_detail),
self.assertRaises(ServiceException) as admin_raised,
):
await ShipmentService.shipment_detail_services(self.db, 404, make_user(1), TestBatchScope.formal())
self.assertEqual('发货单不存在', admin_raised.exception.message)
get_detail.assert_awaited_once_with(
self.db,
404,
TestBatchScope.formal(),
warehouse_ids=None,
site_ids=None,
)
async def test_scan_hides_missing_qr_external_warehouse_and_site_location(self) -> None:
route_mocks = (
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
)
scenarios = (
(None, None),
(make_pallet(2), make_inventory(2, warehouse_id=8)),
(make_pallet(2), make_inventory(2, site_id=11)),
)
for pallet, inventory in scenarios:
with self.subTest(pallet=pallet, inventory=inventory):
with (
route_mocks[0],
route_mocks[1],
route_mocks[2],
route_mocks[3],
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(PermissionException) as raised,
):
await ShipmentService.scan_shipment_pallet_services(
self.db,
'hidden-token',
ShipmentScanQueryModel(sourceWarehouseId=7, targetSiteId=9),
self.user,
TestBatchScope.formal(),
)
self.assertEqual('无权访问该托盘', raised.exception.message)
async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None:
scope = make_test_scope(4)
query = ShipmentScanQueryModel(sourceWarehouseId=7, targetSiteId=9)
with (
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
test_batch_id=4,
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
test_batch_id=4,
)
),
),
patch.object(
InventoryDao,
'get_pallet_by_qr_token',
new=AsyncMock(return_value=make_pallet(2, test_batch_id=5)),
),
patch.object(
MasterDao,
'get_pallet_inventory',
new=AsyncMock(return_value=make_inventory(2, test_batch_id=4)),
),
patch.object(
OperationDao,
'get_filling_by_id',
new=AsyncMock(return_value=make_filling(2, test_batch_id=4)),
),
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
):
await ShipmentService.scan_shipment_pallet_services(self.db, 'qr-2', query, self.user, scope)
class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.user = make_user()
self.fixed_time = datetime(2026, 7, 26, 16, 30)
async def test_residual_return_can_be_re_shipped_from_current_inventory_warehouse(self) -> None:
inventory = make_inventory(2, warehouse_id=7, remaining_packages=6)
pallet = make_pallet(2)
origin_filling = make_filling(2, warehouse_id=3)
with (
patch.object(
InventoryDao,
'get_inventories_for_update',
new=AsyncMock(return_value=[inventory]),
),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[pallet])),
patch.object(
OperationDao,
'get_active_filling_for_update',
new=AsyncMock(return_value=origin_filling),
),
):
inventories, pallets, fillings = await ShipmentService._lock_and_validate_pallets(
self.db,
[2],
7,
TestBatchScope.formal(),
)
self.assertEqual([inventory], inventories)
self.assertEqual([pallet], pallets)
self.assertEqual([origin_filling], fillings)
async def test_test_scope_write_rejects_cross_batch_inventory_and_rolls_back(self) -> None:
scope = make_test_scope(4)
page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[2])
add_shipment = AsyncMock()
with (
patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock()),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
test_batch_id=4,
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
test_batch_id=4,
)
),
),
patch.object(
InventoryDao,
'get_inventories_for_update',
new=AsyncMock(return_value=[make_inventory(2, test_batch_id=5)]),
),
patch.object(
InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[make_pallet(2, test_batch_id=4)])
),
patch.object(OperationDao, 'add_shipment', new=add_shipment),
self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'),
):
await ShipmentService.add_shipment_services(self.db, page, self.user, scope)
add_shipment.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_add_only_persists_pallet_ids_with_null_snapshots(self) -> None: # noqa: PLR0915
page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[5, 2])
inventories = [make_inventory(2), make_inventory(5)]
pallets = [make_pallet(2), make_pallet(5)]
fillings = {102: make_filling(2), 105: make_filling(5)}
captured: dict[str, Any] = {}
actions: list[str] = []
async def warehouse_access(*_args: Any, **_kwargs: Any) -> None:
actions.append('warehouse-access')
async def site_access(*_args: Any, **_kwargs: Any) -> None:
actions.append('site-access')
async def get_warehouse(*_args: Any, **_kwargs: Any) -> BizWarehouse:
actions.append('warehouse-read')
return BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
async def get_site(*_args: Any, **_kwargs: Any) -> BizCustomerSite:
actions.append('site-read')
return BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
async def lock_inventories(*_args: Any) -> list[BizPalletInventory]:
actions.append('inventory-lock')
return inventories
async def lock_pallets(*_args: Any) -> list[BizPallet]:
actions.append('pallet-lock')
return pallets
async def lock_filling(
_db: AsyncSession,
filling_id: int,
_scope: TestBatchScope,
) -> BizFillingRecord:
actions.append(f'filling-lock-{filling_id}')
return fillings[filling_id]
async def add_order(_db: AsyncSession, shipment: BizShipmentOrder) -> BizShipmentOrder:
actions.append('add-order')
shipment.shipment_id = 12
captured['shipment'] = shipment
return shipment
async def replace_items(
_db: AsyncSession,
shipment_id: int,
items: list[BizShipmentItem],
) -> list[BizShipmentItem]:
actions.append('replace-items')
captured['items'] = items
self.assertEqual(12, shipment_id)
return items
async def commit() -> None:
actions.append('commit')
self.db.commit.side_effect = commit
add_event = AsyncMock()
with (
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=warehouse_access)),
patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=site_access)),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=get_warehouse)),
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=get_site)),
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,
'get_active_filling_for_update',
new=AsyncMock(side_effect=lock_filling),
),
patch.object(OperationDao, 'add_shipment', new=AsyncMock(side_effect=add_order)),
patch.object(OperationDao, 'replace_shipment_items', new=AsyncMock(side_effect=replace_items)),
patch.object(InventoryDao, 'add_event', new=add_event),
patch.object(shipment_service_module, 'datetime', create=True) as service_datetime,
patch.object(shipment_service_module, 'secrets', create=True) as service_secrets,
):
service_datetime.now.return_value = self.fixed_time
service_secrets.token_hex.return_value = 'draft-token'
try:
result = await ShipmentService.add_shipment_services(self.db, page, self.user, TestBatchScope.formal())
except NotImplementedError:
self.fail('发货草稿新增尚未实现')
self.assertTrue(result.is_success)
self.assertEqual('DRAFT', captured['shipment'].status)
self.assertEqual('SH-draft-token', captured['shipment'].shipment_no)
self.assertEqual([2, 5], [item.pallet_id for item in captured['items']])
self.assertTrue(all(item.active_filling_id is None for item in captured['items']))
self.assertTrue(all(item.package_qty is None for item in captured['items']))
inventory_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal())
pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal())
self.assertEqual(
[
'warehouse-access',
'warehouse-read',
'site-access',
'site-read',
'inventory-lock',
'pallet-lock',
'filling-lock-102',
'filling-lock-105',
'warehouse-read',
'warehouse-access',
'site-read',
'site-access',
'add-order',
'replace-items',
'commit',
],
actions,
)
self.assertEqual(['FILLED_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE'], [item.flow_state for item in inventories])
add_event.assert_not_awaited()
self.db.commit.assert_awaited_once()
self.db.rollback.assert_not_awaited()
async def test_add_rechecks_revoked_mapping_after_pallet_locks_before_writing(self) -> None:
page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[2])
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
if kwargs.get('for_update'):
raise PermissionException(data='', message='无权访问该仓库')
add_order = AsyncMock(return_value=make_shipment(pallet_ids=(2,)))
replace_items = AsyncMock()
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
with (
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
) as warehouse_read,
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
patch.object(
ShipmentService,
'_lock_and_validate_pallets',
new=AsyncMock(return_value=([], [], [])),
),
patch.object(OperationDao, 'add_shipment', new=add_order),
patch.object(OperationDao, 'replace_shipment_items', new=replace_items),
self.assertRaises(PermissionException) as raised,
):
await ShipmentService.add_shipment_services(self.db, page, self.user, TestBatchScope.formal())
self.assertEqual('无权访问该仓库', raised.exception.message)
self.assertEqual(2, warehouse_read.await_count)
warehouse_read.assert_awaited_with(self.db, 7, TestBatchScope.formal(), for_update=True)
self.assertEqual(2, warehouse_mapping.await_count)
warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True)
add_order.assert_not_awaited()
replace_items.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_add_rechecks_disabled_master_after_pallet_locks_before_writing(self) -> None:
page = ShipmentCreateModel(sourceWarehouseId=7, targetSiteId=9, palletIds=[2])
async def get_warehouse(*_args: Any, **kwargs: Any) -> BizWarehouse:
return BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='1' if kwargs.get('for_update') else '0',
)
add_order = AsyncMock(return_value=make_shipment(pallet_ids=(2,)))
replace_items = AsyncMock()
warehouse_read = AsyncMock(side_effect=get_warehouse)
with (
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(MasterDao, 'get_warehouse', new=warehouse_read),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
patch.object(
ShipmentService,
'_lock_and_validate_pallets',
new=AsyncMock(return_value=([], [], [])),
),
patch.object(OperationDao, 'add_shipment', new=add_order),
patch.object(OperationDao, 'replace_shipment_items', new=replace_items),
self.assertRaises(ServiceException) as raised,
):
await ShipmentService.add_shipment_services(self.db, page, self.user, TestBatchScope.formal())
self.assertEqual('来源仓库已停用', raised.exception.message)
warehouse_read.assert_awaited_with(self.db, 7, TestBatchScope.formal(), for_update=True)
add_order.assert_not_awaited()
replace_items.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_edit_revalidates_current_draft_and_keeps_snapshots_null(self) -> None:
page = ShipmentUpdateModel(
shipmentId=12,
sourceWarehouseId=8,
targetSiteId=10,
palletIds=[3],
)
shipment = make_shipment()
inventory = make_inventory(3, warehouse_id=8)
pallet = make_pallet(3)
filling = make_filling(3, warehouse_id=8)
captured: dict[str, Any] = {}
async def update_order(_db: AsyncSession, updated: BizShipmentOrder) -> BizShipmentOrder:
captured['shipment'] = updated
return updated
async def replace_items(
_db: AsyncSession,
_shipment_id: int,
items: list[BizShipmentItem],
) -> list[BizShipmentItem]:
captured['items'] = items
return items
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])),
patch.object(
OperationDao,
'get_shipment_for_update',
new=AsyncMock(return_value=shipment),
) as lock_order,
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=8,
warehouse_code='W-8',
warehouse_name='八号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=10,
customer_id=1,
site_code='S-10',
site_name='十号现场',
status='0',
)
),
),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=[inventory])),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[pallet])),
patch.object(
OperationDao,
'get_active_filling_for_update',
new=AsyncMock(return_value=filling),
),
patch.object(OperationDao, 'update_shipment', new=AsyncMock(side_effect=update_order)),
patch.object(OperationDao, 'replace_shipment_items', new=AsyncMock(side_effect=replace_items)),
patch.object(shipment_service_module, 'datetime') as service_datetime,
):
service_datetime.now.return_value = self.fixed_time
try:
result = await ShipmentService.edit_shipment_services(self.db, page, self.user, TestBatchScope.formal())
except NotImplementedError:
self.fail('发货草稿编辑尚未实现')
self.assertTrue(result.is_success)
lock_order.assert_awaited_once_with(
self.db,
12,
TestBatchScope.formal(),
warehouse_ids=[7, 8],
site_ids=[9, 10],
)
self.assertEqual(8, captured['shipment'].source_warehouse_id)
self.assertEqual(10, captured['shipment'].target_site_id)
self.assertEqual([3], [item.pallet_id for item in captured['items']])
self.assertIsNone(captured['items'][0].active_filling_id)
self.assertIsNone(captured['items'][0].package_qty)
self.assertEqual('FILLED_IN_WAREHOUSE', inventory.flow_state)
self.db.commit.assert_awaited_once()
async def test_edit_rechecks_old_and_new_routes_before_writing(self) -> None:
scenarios = (
('current-warehouse-revoked', PermissionException, '无权访问该发货单'),
('new-site-revoked', PermissionException, '无权访问该客户现场'),
('new-site-disabled', ServiceException, '目标现场已停用'),
)
for scenario, exception_type, expected_message in scenarios:
with self.subTest(scenario=scenario):
db = MagicMock(spec=AsyncSession)
shipment = make_shipment(pallet_ids=(2,))
page = ShipmentUpdateModel(
shipmentId=12,
sourceWarehouseId=8,
targetSiteId=10,
palletIds=[2],
)
current_warehouse_id = shipment.source_warehouse_id
new_site_id = page.target_site_id
async def warehouse_access(
_db: AsyncSession,
_user: CurrentUserModel,
warehouse_id: int,
_scenario: str = scenario,
_current_warehouse_id: int = current_warehouse_id,
**kwargs: Any,
) -> None:
if (
_scenario == 'current-warehouse-revoked'
and warehouse_id == _current_warehouse_id
and kwargs.get('for_update')
):
raise PermissionException(data='', message='无权访问该仓库')
async def site_access(
_db: AsyncSession,
_user: CurrentUserModel,
site_id: int,
_scenario: str = scenario,
_new_site_id: int = new_site_id,
**kwargs: Any,
) -> None:
if _scenario == 'new-site-revoked' and site_id == _new_site_id and kwargs.get('for_update'):
raise PermissionException(data='', message='无权访问该客户现场')
async def get_warehouse(
_db: AsyncSession,
warehouse_id: int,
_scope: TestBatchScope,
**_kwargs: Any,
) -> BizWarehouse:
return BizWarehouse(
warehouse_id=warehouse_id,
warehouse_code=f'W-{warehouse_id}',
warehouse_name=f'{warehouse_id}号仓',
status='0',
)
async def get_site(
_db: AsyncSession,
site_id: int,
_scope: TestBatchScope,
_scenario: str = scenario,
_new_site_id: int = new_site_id,
**kwargs: Any,
) -> BizCustomerSite:
return BizCustomerSite(
site_id=site_id,
customer_id=1,
site_code=f'S-{site_id}',
site_name=f'{site_id}号现场',
status=(
'1'
if _scenario == 'new-site-disabled' and site_id == _new_site_id and kwargs.get('for_update')
else '0'
),
)
update = AsyncMock()
replace_items = AsyncMock()
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
site_mapping = AsyncMock(side_effect=site_access)
site_read = AsyncMock(side_effect=get_site)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=shipment)),
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
patch.object(AccessService, 'assert_site_access', new=site_mapping),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=get_warehouse)),
patch.object(MasterDao, 'get_site', new=site_read),
patch.object(
ShipmentService,
'_lock_and_validate_pallets',
new=AsyncMock(return_value=([], [], [])),
),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(OperationDao, 'replace_shipment_items', new=replace_items),
self.assertRaises(exception_type) as raised,
):
await ShipmentService.edit_shipment_services(db, page, self.user, TestBatchScope.formal())
self.assertEqual(expected_message, raised.exception.message)
if scenario == 'current-warehouse-revoked':
warehouse_mapping.assert_any_await(db, self.user, 7, for_update=True)
else:
site_read.assert_any_await(db, 10, TestBatchScope.formal(), for_update=True)
if scenario == 'new-site-revoked':
site_mapping.assert_any_await(db, self.user, 10, for_update=True)
update.assert_not_awaited()
replace_items.assert_not_awaited()
db.commit.assert_not_awaited()
db.rollback.assert_awaited_once()
async def test_edit_hidden_current_route_denial_wins_after_all_stable_route_locks(self) -> None:
scenarios = (
('both-warehouses-revoked', {7, 8}, set(), set(), set()),
('new-warehouse-and-current-site-revoked', {7}, {10}, set(), set()),
('current-warehouse-disabled-and-revoked', {8}, set(), {8}, set()),
('current-site-disabled-and-revoked', set(), {10}, set(), {10}),
)
expected_locks = [
'warehouse-master-7',
'warehouse-mapping-7',
'warehouse-master-8',
'warehouse-mapping-8',
'site-master-9',
'site-mapping-9',
'site-master-10',
'site-mapping-10',
]
for (
scenario,
denied_warehouse_ids,
denied_site_ids,
disabled_warehouse_ids,
disabled_site_ids,
) in scenarios:
with self.subTest(scenario=scenario):
db = MagicMock(spec=AsyncSession)
shipment = make_shipment(
source_warehouse_id=8,
target_site_id=10,
pallet_ids=(2,),
)
page = ShipmentUpdateModel(
shipmentId=12,
sourceWarehouseId=7,
targetSiteId=9,
palletIds=[2],
)
locks: list[str] = []
async def warehouse_access(
_db: AsyncSession,
_user: CurrentUserModel,
warehouse_id: int,
_denied_ids: set[int] = denied_warehouse_ids,
_locks: list[str] = locks,
**kwargs: Any,
) -> None:
if not kwargs.get('for_update'):
return
_locks.append(f'warehouse-mapping-{warehouse_id}')
if warehouse_id in _denied_ids:
raise PermissionException(data='', message='无权访问该仓库')
async def site_access(
_db: AsyncSession,
_user: CurrentUserModel,
site_id: int,
_denied_ids: set[int] = denied_site_ids,
_locks: list[str] = locks,
**kwargs: Any,
) -> None:
if not kwargs.get('for_update'):
return
_locks.append(f'site-mapping-{site_id}')
if site_id in _denied_ids:
raise PermissionException(data='', message='无权访问该客户现场')
async def get_warehouse(
_db: AsyncSession,
warehouse_id: int,
_scope: TestBatchScope,
_disabled_ids: set[int] = disabled_warehouse_ids,
_locks: list[str] = locks,
**kwargs: Any,
) -> BizWarehouse:
if kwargs.get('for_update'):
_locks.append(f'warehouse-master-{warehouse_id}')
return BizWarehouse(
warehouse_id=warehouse_id,
warehouse_code=f'W-{warehouse_id}',
warehouse_name=f'{warehouse_id}号仓',
status='1' if kwargs.get('for_update') and warehouse_id in _disabled_ids else '0',
)
async def get_site(
_db: AsyncSession,
site_id: int,
_scope: TestBatchScope,
_disabled_ids: set[int] = disabled_site_ids,
_locks: list[str] = locks,
**kwargs: Any,
) -> BizCustomerSite:
if kwargs.get('for_update'):
_locks.append(f'site-master-{site_id}')
return BizCustomerSite(
site_id=site_id,
customer_id=1,
site_code=f'S-{site_id}',
site_name=f'{site_id}号现场',
status='1' if kwargs.get('for_update') and site_id in _disabled_ids else '0',
)
update = AsyncMock()
replace_items = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=shipment)),
patch.object(
AccessService,
'assert_warehouse_access',
new=AsyncMock(side_effect=warehouse_access),
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=site_access)),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=get_warehouse)),
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=get_site)),
patch.object(
ShipmentService,
'_lock_and_validate_pallets',
new=AsyncMock(return_value=([], [], [])),
),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(OperationDao, 'replace_shipment_items', new=replace_items),
self.assertRaises(PermissionException) as raised,
):
await ShipmentService.edit_shipment_services(db, page, self.user, TestBatchScope.formal())
self.assertEqual('无权访问该发货单', raised.exception.message)
self.assertEqual(expected_locks, locks)
update.assert_not_awaited()
replace_items.assert_not_awaited()
db.commit.assert_not_awaited()
db.rollback.assert_awaited_once()
async def test_edit_locks_old_and_new_routes_in_stable_grouped_order(self) -> None:
db = MagicMock(spec=AsyncSession)
shipment = make_shipment(
source_warehouse_id=8,
target_site_id=10,
pallet_ids=(2,),
)
page = ShipmentUpdateModel(
shipmentId=12,
sourceWarehouseId=7,
targetSiteId=9,
palletIds=[2],
)
actions: list[str] = []
async def warehouse_access(
_db: AsyncSession,
_user: CurrentUserModel,
warehouse_id: int,
**kwargs: Any,
) -> None:
if kwargs.get('for_update'):
actions.append(f'warehouse-mapping-{warehouse_id}')
async def site_access(
_db: AsyncSession,
_user: CurrentUserModel,
site_id: int,
**kwargs: Any,
) -> None:
if kwargs.get('for_update'):
actions.append(f'site-mapping-{site_id}')
async def get_warehouse(
_db: AsyncSession,
warehouse_id: int,
_scope: TestBatchScope,
**kwargs: Any,
) -> BizWarehouse:
if kwargs.get('for_update'):
actions.append(f'warehouse-master-{warehouse_id}')
return BizWarehouse(
warehouse_id=warehouse_id,
warehouse_code=f'W-{warehouse_id}',
warehouse_name=f'{warehouse_id}号仓',
status='0',
)
async def get_site(
_db: AsyncSession,
site_id: int,
_scope: TestBatchScope,
**kwargs: Any,
) -> BizCustomerSite:
if kwargs.get('for_update'):
actions.append(f'site-master-{site_id}')
return BizCustomerSite(
site_id=site_id,
customer_id=1,
site_code=f'S-{site_id}',
site_name=f'{site_id}号现场',
status='0',
)
async def lock_pallets(*_args: Any, **_kwargs: Any) -> tuple[list[Any], list[Any], list[Any]]:
actions.append('pallet-locks')
return [], [], []
async def update(*_args: Any, **_kwargs: Any) -> BizShipmentOrder:
actions.append('update')
return shipment
async def replace(*_args: Any, **_kwargs: Any) -> list[BizShipmentItem]:
actions.append('replace')
return []
async def commit() -> None:
actions.append('commit')
db.commit.side_effect = commit
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=shipment)),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=warehouse_access)),
patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=site_access)),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=get_warehouse)),
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=get_site)),
patch.object(ShipmentService, '_lock_and_validate_pallets', new=AsyncMock(side_effect=lock_pallets)),
patch.object(OperationDao, 'update_shipment', new=AsyncMock(side_effect=update)),
patch.object(OperationDao, 'replace_shipment_items', new=AsyncMock(side_effect=replace)),
):
result = await ShipmentService.edit_shipment_services(db, page, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
self.assertEqual(
[
'pallet-locks',
'warehouse-master-7',
'warehouse-mapping-7',
'warehouse-master-8',
'warehouse-mapping-8',
'site-master-9',
'site-mapping-9',
'site-master-10',
'site-mapping-10',
'update',
'replace',
'commit',
],
actions,
)
async def test_batch_delete_preflights_all_drafts_before_any_delete(self) -> None:
lock = AsyncMock()
delete = AsyncMock()
with (
patch.object(OperationDao, 'get_shipment_for_update', new=lock),
patch.object(OperationDao, 'delete_shipment', new=delete),
self.assertRaises(ServiceWarning) as raised,
):
await ShipmentService.delete_shipment_services(self.db, [12, 11], self.user, TestBatchScope.formal())
self.assertEqual('不支持单条物理删除,请通过测试数据批次清理', raised.exception.message)
lock.assert_not_awaited()
delete.assert_not_awaited()
self.db.commit.assert_not_awaited()
async def test_batch_delete_rechecks_all_current_routes_before_first_delete(self) -> None:
lock = AsyncMock()
delete = AsyncMock()
with (
patch.object(OperationDao, 'get_shipment_for_update', new=lock),
patch.object(OperationDao, 'delete_shipment', new=delete),
self.assertRaises(ServiceWarning) as raised,
):
await ShipmentService.delete_shipment_services(self.db, [12, 11], self.user, TestBatchScope.formal())
self.assertEqual('不支持单条物理删除,请通过测试数据批次清理', raised.exception.message)
lock.assert_not_awaited()
delete.assert_not_awaited()
self.db.commit.assert_not_awaited()
async def test_batch_delete_locks_deduplicated_routes_stably_without_requiring_active(self) -> None:
db = MagicMock(spec=AsyncSession)
lock = AsyncMock()
delete = AsyncMock()
with (
patch.object(OperationDao, 'get_shipment_for_update', new=lock),
patch.object(OperationDao, 'delete_shipment', new=delete),
self.assertRaises(ServiceWarning) as raised,
):
await ShipmentService.delete_shipment_services(
db,
[13, 11, 12],
self.user,
TestBatchScope.formal(),
)
self.assertEqual('不支持单条物理删除,请通过测试数据批次清理', raised.exception.message)
lock.assert_not_awaited()
delete.assert_not_awaited()
db.commit.assert_not_awaited()
class ShipmentConfirmServiceTest(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, 17, 20)
self.request_hash = ShipmentService._build_confirm_request_hash(12)
async def test_confirm_snapshots_full_remaining_and_moves_every_pallet_atomically( # noqa: PLR0915
self,
) -> None:
shipment = make_shipment()
inventories = [
make_inventory(2, remaining_packages=8, version=4),
make_inventory(5, remaining_packages=13, version=8),
]
pallets = [make_pallet(2), make_pallet(5)]
fillings = {102: make_filling(2), 105: make_filling(5)}
actions: list[str] = []
events: list[BizPalletEvent] = []
async def detail(*_args: Any, **_kwargs: Any) -> BizShipmentOrder:
actions.append('detail')
return shipment
async def idempotency(*_args: Any, **_kwargs: Any) -> None:
actions.append('idempotency-read')
async def order_lock(*_args: Any, **_kwargs: Any) -> BizShipmentOrder:
actions.append('order-lock')
return shipment
async def inventory_lock(*_args: Any) -> list[BizPalletInventory]:
actions.append('inventory-lock')
return inventories
async def pallet_lock(*_args: Any) -> list[BizPallet]:
actions.append('pallet-lock')
return pallets
async def filling_lock(
_db: AsyncSession,
filling_id: int,
_scope: TestBatchScope,
) -> BizFillingRecord:
actions.append(f'filling-lock-{filling_id}')
return fillings[filling_id]
async def warehouse_lock(*_args: Any, **kwargs: Any) -> BizWarehouse:
self.assertTrue(kwargs.get('for_update'))
actions.append('warehouse-lock')
return BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
async def warehouse_mapping(*_args: Any, **kwargs: Any) -> None:
self.assertTrue(kwargs.get('for_update'))
actions.append('warehouse-mapping-lock')
async def site_lock(*_args: Any, **kwargs: Any) -> BizCustomerSite:
self.assertTrue(kwargs.get('for_update'))
actions.append('site-lock')
return BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
async def site_mapping(*_args: Any, **kwargs: Any) -> None:
self.assertTrue(kwargs.get('for_update'))
actions.append('site-mapping-lock')
def transition(current: PalletFlowState, target: PalletFlowState) -> None:
actions.append(f'transition-{current.value}-{target.value}')
async def update(*_args: Any) -> BizShipmentOrder:
actions.append('update-order')
return shipment
async def add_event(
_db: AsyncSession,
inventory: BizPalletInventory,
event: BizPalletEvent,
_scope: TestBatchScope,
) -> BizPalletEvent:
actions.append(f'event-{inventory.pallet_id}')
event.event_id = 200 + inventory.pallet_id
inventory.last_event_id = event.event_id
events.append(event)
return event
async def commit() -> None:
actions.append('commit')
self.db.commit.side_effect = commit
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(side_effect=detail)) as detail_read,
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(side_effect=idempotency),
),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(side_effect=order_lock)),
patch.object(
InventoryDao,
'get_inventories_for_update',
new=AsyncMock(side_effect=inventory_lock),
),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=pallet_lock)),
patch.object(
OperationDao,
'get_active_filling_for_update',
new=AsyncMock(side_effect=filling_lock),
),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=warehouse_lock)),
patch.object(
AccessService,
'assert_warehouse_access',
new=AsyncMock(side_effect=warehouse_mapping),
),
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=site_lock)),
patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=site_mapping)),
patch.object(PalletStateMachine, 'ensure_transition', side_effect=transition),
patch.object(OperationDao, 'update_shipment', new=AsyncMock(side_effect=update)),
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
patch.object(shipment_service_module, 'datetime') as service_datetime,
):
service_datetime.now.return_value = self.fixed_time
try:
result = await ShipmentService.confirm_shipment_services(
self.db,
12,
self.command,
self.user,
TestBatchScope.formal(),
)
except NotImplementedError:
self.fail('多托盘整托确认尚未实现')
self.assertTrue(result.is_success)
self.assertEqual(
[
'detail',
'idempotency-read',
'order-lock',
'inventory-lock',
'pallet-lock',
'filling-lock-102',
'filling-lock-105',
'warehouse-lock',
'warehouse-mapping-lock',
'site-lock',
'site-mapping-lock',
'transition-FILLED_IN_WAREHOUSE-AT_SITE_UNUSED',
'transition-FILLED_IN_WAREHOUSE-AT_SITE_UNUSED',
'update-order',
'event-2',
'event-5',
'commit',
],
actions,
)
detail_read.assert_awaited_once_with(
self.db,
12,
TestBatchScope.formal(),
warehouse_ids=[7],
site_ids=[9],
)
self.assertEqual('CONFIRMED', shipment.status)
self.assertEqual('confirm-key', shipment.confirm_idempotency_key)
self.assertEqual(self.request_hash, shipment.confirm_request_hash)
self.assertEqual(7, shipment.confirmed_by)
self.assertEqual(self.fixed_time, shipment.confirmed_at)
snapshots = {item.pallet_id: (item.active_filling_id, item.package_qty) for item in shipment.items}
self.assertEqual({2: (102, 8), 5: (105, 13)}, snapshots)
for inventory, original_version, remaining in zip(
inventories,
(4, 8),
(8, 13),
strict=True,
):
self.assertEqual('AT_SITE_UNUSED', inventory.flow_state)
self.assertIsNone(inventory.warehouse_id)
self.assertEqual(9, inventory.site_id)
self.assertIsNone(inventory.current_line_id)
self.assertEqual(100 + inventory.pallet_id, inventory.active_filling_id)
self.assertEqual(remaining, inventory.remaining_packages)
self.assertEqual(original_version + 1, inventory.version)
self.assertEqual(self.fixed_time, inventory.updated_at)
self.assertEqual([2, 5], [event.pallet_id for event in events])
for event in events:
self.assertEqual(PalletEventType.SHIPMENT_CONFIRMED.value, event.event_type)
self.assertEqual('SHIPMENT', event.source_type)
self.assertEqual(12, event.source_id)
self.assertEqual('FILLED_IN_WAREHOUSE', event.before_state)
self.assertEqual('AT_SITE_UNUSED', event.after_state)
self.assertEqual(7, event.before_warehouse_id)
self.assertIsNone(event.before_site_id)
self.assertIsNone(event.after_warehouse_id)
self.assertEqual(9, event.after_site_id)
self.assertEqual(event.before_remaining_packages, 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()
async def test_rr_current_parent_recovers_exact_confirm_without_repeating_inventory_writes(self) -> None:
initial = make_shipment(status='DRAFT')
confirmed = make_shipment(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
inventory_lock = AsyncMock()
update = AsyncMock()
add_event = AsyncMock()
warehouse_access = AsyncMock()
site_access = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=initial)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(return_value=None),
),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=confirmed)),
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
) as warehouse_lock,
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_access),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
) as site_lock,
patch.object(AccessService, 'assert_site_access', new=site_access),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(InventoryDao, 'add_event', new=add_event),
):
result = await ShipmentService.confirm_shipment_services(
self.db,
12,
self.command,
self.user,
TestBatchScope.formal(),
)
self.assertTrue(result.is_success)
warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True)
warehouse_access.assert_awaited_once_with(self.db, self.user, 7, for_update=True)
site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True)
site_access.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
inventory_lock.assert_not_awaited()
update.assert_not_awaited()
add_event.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_exact_confirm_retry_ignores_later_route_disable_but_rechecks_current_access(
self,
) -> None:
for warehouse_status, site_status in (('1', '0'), ('0', '1')):
with self.subTest(warehouse_status=warehouse_status, site_status=site_status):
db = MagicMock(spec=AsyncSession)
confirmed = make_shipment(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
for item in confirmed.items:
item.active_filling_id = 100 + item.pallet_id
item.package_qty = 8
original_snapshots = [
(item.pallet_id, item.active_filling_id, item.package_qty) for item in confirmed.items
]
inventory_lock = AsyncMock()
update = AsyncMock()
add_event = AsyncMock()
warehouse_access = AsyncMock()
site_access = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=confirmed)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(return_value=confirmed),
),
patch.object(
OperationDao,
'get_shipment_for_update',
new=AsyncMock(return_value=confirmed),
),
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status=warehouse_status,
)
),
) as warehouse_lock,
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_access),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status=site_status,
)
),
) as site_lock,
patch.object(AccessService, 'assert_site_access', new=site_access),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(InventoryDao, 'add_event', new=add_event),
):
result = await ShipmentService.confirm_shipment_services(
db,
12,
self.command,
self.user,
TestBatchScope.formal(),
)
self.assertTrue(result.is_success)
warehouse_lock.assert_awaited_once_with(db, 7, TestBatchScope.formal(), for_update=True)
warehouse_access.assert_awaited_once_with(db, self.user, 7, for_update=True)
site_lock.assert_awaited_once_with(db, 9, TestBatchScope.formal(), for_update=True)
site_access.assert_awaited_once_with(db, self.user, 9, for_update=True)
self.assertEqual(
original_snapshots,
[(item.pallet_id, item.active_filling_id, item.package_qty) for item in confirmed.items],
)
inventory_lock.assert_not_awaited()
update.assert_not_awaited()
add_event.assert_not_awaited()
db.commit.assert_not_awaited()
db.rollback.assert_awaited_once()
async def test_exact_confirm_retry_hides_document_when_current_mapping_is_revoked(self) -> None:
confirmed = make_shipment(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
inventory_lock = AsyncMock()
update = AsyncMock()
add_event = AsyncMock()
site_lock = AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
)
site_mapping = AsyncMock()
warehouse_mapping = AsyncMock(side_effect=PermissionException(data='', message='无权访问该仓库'))
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=confirmed)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(return_value=confirmed),
),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=confirmed)),
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='1',
)
),
) as warehouse_lock,
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
patch.object(MasterDao, 'get_site', new=site_lock),
patch.object(AccessService, 'assert_site_access', new=site_mapping),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(InventoryDao, 'add_event', new=add_event),
self.assertRaises(PermissionException) as raised,
):
await ShipmentService.confirm_shipment_services(
self.db,
12,
self.command,
self.user,
TestBatchScope.formal(),
)
self.assertEqual('无权访问该发货单', raised.exception.message)
warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True)
warehouse_mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True)
site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True)
site_mapping.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
inventory_lock.assert_not_awaited()
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_uses_locked_current_items_not_initial_rr_snapshot(self) -> None:
initial = make_shipment(pallet_ids=(99,))
locked = make_shipment(pallet_ids=(2,))
inventory = make_inventory(2, remaining_packages=6)
lock_validate = AsyncMock(return_value=([inventory], [make_pallet(2)], [make_filling(2)]))
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=initial)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(return_value=None),
),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=locked)),
patch.object(ShipmentService, '_lock_and_validate_pallets', new=lock_validate),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(PalletStateMachine, 'ensure_transition'),
patch.object(OperationDao, 'update_shipment', new=AsyncMock()),
patch.object(InventoryDao, 'add_event', new=AsyncMock()),
):
result = await ShipmentService.confirm_shipment_services(
self.db,
12,
self.command,
self.user,
TestBatchScope.formal(),
)
self.assertTrue(result.is_success)
lock_validate.assert_awaited_once_with(self.db, [2], 7, TestBatchScope.formal())
self.assertEqual((102, 6), (locked.items[0].active_filling_id, locked.items[0].package_qty))
self.assertIsNone(initial.items[0].active_filling_id)
self.assertIsNone(initial.items[0].package_qty)
async def test_same_key_on_other_shipment_is_rejected_before_parent_lock(self) -> None:
target = make_shipment()
other = make_shipment(
13,
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=ShipmentService._build_confirm_request_hash(13),
)
parent_lock = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=target)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(return_value=other),
),
patch.object(OperationDao, 'get_shipment_for_update', new=parent_lock),
self.assertRaises(ServiceException) as raised,
):
await ShipmentService.confirm_shipment_services(
self.db, 12, self.command, self.user, TestBatchScope.formal()
)
self.assertEqual('确认发货失败,幂等键已被其他请求使用', raised.exception.message)
parent_lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_same_key_retry_uses_exact_route_when_user_can_access_duplicate_keys(self) -> None:
target = make_shipment(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
other_domain = make_shipment(
13,
source_warehouse_id=8,
target_site_id=10,
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=ShipmentService._build_confirm_request_hash(13),
)
async def read_key(
_db: AsyncSession,
_key: str,
_scope: TestBatchScope,
*,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> BizShipmentOrder | None:
matches = [
shipment
for shipment in (target, other_domain)
if (warehouse_ids is None or shipment.source_warehouse_id in warehouse_ids)
and (site_ids is None or shipment.target_site_id in site_ids)
]
if len(matches) > 1:
raise MultipleResultsFound('广域幂等键查询返回多条发货单')
return matches[0] if matches else None
get_detail = AsyncMock(return_value=target)
key_read = AsyncMock(side_effect=read_key)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])),
patch.object(OperationDao, 'get_shipment_by_id', new=get_detail),
patch.object(OperationDao, 'get_shipment_by_confirm_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=target)),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
):
result = await ShipmentService.confirm_shipment_services(
self.db,
12,
self.command,
self.user,
TestBatchScope.formal(),
)
self.assertTrue(result.is_success)
get_detail.assert_awaited_once_with(
self.db,
12,
TestBatchScope.formal(),
warehouse_ids=[7, 8],
site_ids=[9, 10],
)
key_read.assert_awaited_once_with(
self.db,
'confirm-key',
TestBatchScope.formal(),
warehouse_ids=[7],
site_ids=[9],
)
self.db.rollback.assert_awaited_once()
async def test_integrity_recovery_nonexact_result_is_stable_conflict_not_success(self) -> None:
initial = make_shipment()
nonexact = make_shipment(
13,
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=ShipmentService._build_confirm_request_hash(13),
)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(side_effect=[[7], [7]])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(side_effect=[[9], [9]])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=initial)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(side_effect=[None, nonexact]),
),
patch.object(
OperationDao,
'get_shipment_for_update',
new=AsyncMock(side_effect=integrity_error()),
),
self.assertRaises(ServiceException) as raised,
):
await ShipmentService.confirm_shipment_services(
self.db, 12, self.command, self.user, TestBatchScope.formal()
)
self.assertEqual('确认发货失败,幂等键已被其他请求使用', raised.exception.message)
self.assertEqual(2, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
async def test_integrity_recovery_locks_parent_and_rechecks_revoked_current_mapping(self) -> None:
initial = make_shipment(status='DRAFT')
recovered = make_shipment(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
parent_lock_count = 0
async def lock_parent(*_args: Any, **_kwargs: Any) -> BizShipmentOrder:
nonlocal parent_lock_count
parent_lock_count += 1
if parent_lock_count == 1:
raise integrity_error()
return recovered
async def warehouse_access(*_args: Any, **kwargs: Any) -> None:
if kwargs.get('for_update'):
raise PermissionException(data='', message='无权访问该仓库')
parent_lock = AsyncMock(side_effect=lock_parent)
warehouse_mapping = AsyncMock(side_effect=warehouse_access)
site_lock = AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(side_effect=[[7], [7]])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(side_effect=[[9], [9]])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=initial)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(side_effect=[None, recovered]),
),
patch.object(OperationDao, 'get_shipment_for_update', new=parent_lock),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
) as warehouse_lock,
patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping),
patch.object(MasterDao, 'get_site', new=site_lock),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
self.assertRaises(PermissionException) as raised,
):
await ShipmentService.confirm_shipment_services(
self.db, 12, self.command, self.user, TestBatchScope.formal()
)
self.assertEqual('无权访问该发货单', raised.exception.message)
self.assertEqual(2, parent_lock.await_count)
parent_lock.assert_awaited_with(
self.db,
12,
TestBatchScope.formal(),
warehouse_ids=[7],
site_ids=[9],
)
warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True)
warehouse_mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True)
site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True)
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_disabled_source_after_pallet_locks_rolls_back_before_mutation(self) -> None:
shipment = make_shipment(pallet_ids=(2,))
inventory = make_inventory(2, version=4)
update = AsyncMock()
add_event = AsyncMock()
mapping = AsyncMock()
site_lock = AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
)
site_mapping = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=shipment)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(return_value=None),
),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=shipment)),
patch.object(
ShipmentService,
'_lock_and_validate_pallets',
new=AsyncMock(return_value=([inventory], [make_pallet(2)], [make_filling(2)])),
),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='停用仓',
status='1',
)
),
),
patch.object(AccessService, 'assert_warehouse_access', new=mapping),
patch.object(MasterDao, 'get_site', new=site_lock),
patch.object(AccessService, 'assert_site_access', new=site_mapping),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(InventoryDao, 'add_event', new=add_event),
self.assertRaises(ServiceException) as raised,
):
await ShipmentService.confirm_shipment_services(
self.db, 12, self.command, self.user, TestBatchScope.formal()
)
self.assertEqual('来源仓库已停用', raised.exception.message)
self.assertEqual('DRAFT', shipment.status)
self.assertEqual('FILLED_IN_WAREHOUSE', inventory.flow_state)
self.assertEqual(4, inventory.version)
mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True)
site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True)
site_mapping.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
update.assert_not_awaited()
add_event.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_integrity_error_recovers_only_fresh_scoped_exact_result(self) -> None:
initial = make_shipment(status='DRAFT')
recovered = make_shipment(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
warehouse_scope = AsyncMock(side_effect=[[7], [7]])
site_scope = AsyncMock(side_effect=[[9], [9]])
key_read = AsyncMock(side_effect=[None, recovered])
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
patch.object(AccessDao, 'list_user_site_ids', new=site_scope),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=initial)),
patch.object(OperationDao, 'get_shipment_by_confirm_idempotency_key', new=key_read),
patch.object(
OperationDao,
'get_shipment_for_update',
new=AsyncMock(side_effect=[integrity_error(), recovered]),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
):
result = await ShipmentService.confirm_shipment_services(
self.db,
12,
self.command,
self.user,
TestBatchScope.formal(),
)
self.assertTrue(result.is_success)
self.assertEqual(2, warehouse_scope.await_count)
self.assertEqual(2, site_scope.await_count)
self.assertEqual(2, key_read.await_count)
for call in key_read.await_args_list:
self.assertEqual([7], call.kwargs['warehouse_ids'])
self.assertEqual([9], call.kwargs['site_ids'])
self.assertEqual(2, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
async def test_integrity_recovery_uses_exact_route_with_duplicate_visible_keys(self) -> None:
initial = make_shipment(status='DRAFT')
recovered = make_shipment(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
other_domain = make_shipment(
13,
source_warehouse_id=8,
target_site_id=10,
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=ShipmentService._build_confirm_request_hash(13),
)
read_count = 0
async def read_key(
_db: AsyncSession,
_key: str,
_scope: TestBatchScope,
*,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> BizShipmentOrder | None:
nonlocal read_count
read_count += 1
if read_count == 1:
return None
matches = [
shipment
for shipment in (recovered, other_domain)
if (warehouse_ids is None or shipment.source_warehouse_id in warehouse_ids)
and (site_ids is None or shipment.target_site_id in site_ids)
]
if len(matches) > 1:
raise MultipleResultsFound('广域幂等键恢复查询返回多条发货单')
return matches[0] if matches else None
warehouse_scope = AsyncMock(side_effect=[[7, 8], [7, 8]])
site_scope = AsyncMock(side_effect=[[9, 10], [9, 10]])
key_read = AsyncMock(side_effect=read_key)
parent_lock = AsyncMock(side_effect=[integrity_error(), recovered])
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
patch.object(AccessDao, 'list_user_site_ids', new=site_scope),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=initial)),
patch.object(OperationDao, 'get_shipment_by_confirm_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_shipment_for_update', new=parent_lock),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
):
result = await ShipmentService.confirm_shipment_services(
self.db,
12,
self.command,
self.user,
TestBatchScope.formal(),
)
self.assertTrue(result.is_success)
self.assertEqual(2, warehouse_scope.await_count)
self.assertEqual(2, site_scope.await_count)
self.assertEqual(2, parent_lock.await_count)
self.assertEqual(
[
unittest.mock.call(
self.db,
'confirm-key',
TestBatchScope.formal(),
warehouse_ids=[7],
site_ids=[9],
),
unittest.mock.call(
self.db,
'confirm-key',
TestBatchScope.formal(),
warehouse_ids=[7],
site_ids=[9],
),
],
key_read.await_args_list,
)
self.assertEqual(2, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
async def test_revoked_mapping_after_locks_hides_document_and_mutates_nothing(self) -> None:
shipment = make_shipment(pallet_ids=(2,))
inventory = make_inventory(2, version=4)
update = AsyncMock()
add_event = AsyncMock()
transition = MagicMock()
site_lock = AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
)
site_mapping = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=shipment)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(return_value=None),
),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=shipment)),
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,
'get_active_filling_for_update',
new=AsyncMock(return_value=make_filling(2)),
),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
AccessService,
'assert_warehouse_access',
new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该仓库')),
),
patch.object(MasterDao, 'get_site', new=site_lock),
patch.object(AccessService, 'assert_site_access', new=site_mapping),
patch.object(PalletStateMachine, 'ensure_transition', new=transition),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(InventoryDao, 'add_event', new=add_event),
self.assertRaises(PermissionException) as raised,
):
await ShipmentService.confirm_shipment_services(
self.db, 12, self.command, self.user, TestBatchScope.formal()
)
self.assertEqual('无权访问该发货单', raised.exception.message)
self.assertEqual('DRAFT', shipment.status)
self.assertEqual('FILLED_IN_WAREHOUSE', inventory.flow_state)
self.assertEqual(7, inventory.warehouse_id)
self.assertIsNone(inventory.site_id)
self.assertEqual(4, inventory.version)
site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True)
site_mapping.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
transition.assert_not_called()
update.assert_not_awaited()
add_event.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_all_transitions_validate_before_first_mutation(self) -> None:
shipment = make_shipment()
inventories = [make_inventory(2, version=4), make_inventory(5, version=8)]
fillings = {102: make_filling(2), 105: make_filling(5)}
update = AsyncMock()
add_event = AsyncMock()
async def get_filling(
_db: AsyncSession,
filling_id: int,
_scope: TestBatchScope,
) -> BizFillingRecord:
return fillings[filling_id]
transition = MagicMock(side_effect=[None, ValueError('second pallet invalid')])
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=shipment)),
patch.object(
OperationDao,
'get_shipment_by_confirm_idempotency_key',
new=AsyncMock(return_value=None),
),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=shipment)),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=inventories)),
patch.object(
InventoryDao,
'get_pallets_by_ids',
new=AsyncMock(return_value=[make_pallet(2), make_pallet(5)]),
),
patch.object(
OperationDao,
'get_active_filling_for_update',
new=AsyncMock(side_effect=get_filling),
),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(PalletStateMachine, 'ensure_transition', new=transition),
patch.object(OperationDao, 'update_shipment', new=update),
patch.object(InventoryDao, 'add_event', new=add_event),
self.assertRaisesRegex(ValueError, 'second pallet invalid'),
):
await ShipmentService.confirm_shipment_services(
self.db, 12, self.command, self.user, TestBatchScope.formal()
)
self.assertEqual('DRAFT', shipment.status)
self.assertTrue(all(item.active_filling_id is None and item.package_qty is None for item in shipment.items))
self.assertEqual(['FILLED_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE'], [item.flow_state for item in inventories])
self.assertEqual([4, 8], [item.version for item in inventories])
update.assert_not_awaited()
add_event.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
if __name__ == '__main__':
unittest.main()