import hashlib import importlib import importlib.util import inspect import json import unittest from contextlib import ExitStack from datetime import datetime from typing import Any, NoReturn, get_type_hints from unittest.mock import AsyncMock, MagicMock, patch from fastapi import FastAPI from fastapi.routing import APIRoute from pydantic import ValidationError 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.vo import ( CrudResponseModel, DataResponseModel, PageModel, PageResponseModel, ) from exceptions.exception import PermissionException, ServiceException from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel from module_admin.service.login_service import LoginService 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.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine from module_business.entity.do.operation_do import ( BizFillingRecord, BizPalletEvent, BizPalletInventory, BizUsageRecord, ) from module_business.entity.vo.master_vo import LinePageQueryModel from module_business.entity.vo.operation_vo import ( BusinessOptionModel, PalletScanResponseModel, UsageCreateModel, UsageModel, UsagePageQueryModel, UsageSuccessResponseModel, ) from module_business.service.access_service import AccessService from tests.module_business.test_batch_fixtures import make_test_scope SERVICE_SPEC = importlib.util.find_spec('module_business.service.usage_service') CONTROLLER_SPEC = importlib.util.find_spec('module_business.controller.usage_controller') UsageService = None if SERVICE_SPEC is not None: UsageService = getattr( importlib.import_module('module_business.service.usage_service'), 'UsageService', None, ) usage_controller_module = None usage_controller = None if CONTROLLER_SPEC is not None: usage_controller_module = importlib.import_module('module_business.controller.usage_controller') usage_controller = getattr(usage_controller_module, 'usage_controller', None) 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_page( *, qr_token: str = 'qr-2', line_id: int = 4, used_packages: int = 3, idempotency_key: str = 'usage-key', ) -> UsageCreateModel: return UsageCreateModel( qrToken=qr_token, lineId=line_id, usedPackages=used_packages, idempotencyKey=idempotency_key, ) def make_pallet( pallet_id: int = 2, *, qr_token: str = 'qr-2', capacity_packages: 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=qr_token, capacity_packages=capacity_packages, asset_status=asset_status, test_batch_id=test_batch_id, ) def make_inventory( pallet_id: int = 2, *, site_id: int | None = 9, current_line_id: int | None = None, active_filling_id: int | None = 31, remaining_packages: int = 8, flow_state: str = PalletFlowState.AT_SITE_UNUSED.value, version: int = 3, test_batch_id: int | None = None, ) -> BizPalletInventory: return BizPalletInventory( pallet_id=pallet_id, active_filling_id=active_filling_id, warehouse_id=None, site_id=site_id, current_line_id=current_line_id, remaining_packages=remaining_packages, flow_state=flow_state, last_event_id=40, version=version, updated_at=datetime(2026, 7, 25, 9, 0), test_batch_id=test_batch_id, ) def make_filling( filling_id: int = 31, *, pallet_id: int = 2, active_slot: str | None = 'ACTIVE', test_batch_id: int | None = None, ) -> BizFillingRecord: return BizFillingRecord( filling_id=filling_id, filling_no=f'FL-{filling_id}', pallet_id=pallet_id, warehouse_id=7, strain_id=11, strain_code_snapshot='S-011', strain_name_snapshot='香菇 11', batch_no='B-20260726', package_spec='20kg', initial_packages=20, active_slot=active_slot, closed_at=None, idempotency_key='fill-key', request_hash='f' * 64, operator_id=7, operated_at=datetime(2026, 7, 25, 8, 0), test_batch_id=test_batch_id, ) def make_site( site_id: int = 9, *, status: str = '0', test_batch_id: int | None = None, ) -> BizCustomerSite: return BizCustomerSite( site_id=site_id, customer_id=3, site_code=f'SITE-{site_id}', site_name=f'现场 {site_id}', status=status, test_batch_id=test_batch_id, ) def make_line( line_id: int = 4, *, site_id: int = 9, status: str = '0', test_batch_id: int | None = None, ) -> BizProductionLine: return BizProductionLine( line_id=line_id, site_id=site_id, line_code=f'L-{line_id}', line_name=f'产线 {line_id}', status=status, test_batch_id=test_batch_id, ) def make_usage( usage_id: int = 61, *, site_id: int = 9, line_id: int = 4, idempotency_key: str = 'usage-key', request_hash: str = 'a' * 64, test_batch_id: int | None = None, ) -> BizUsageRecord: return BizUsageRecord( usage_id=usage_id, usage_no=f'US-{usage_id}', pallet_id=2, active_filling_id=31, site_id=site_id, line_id=line_id, used_packages=3, remaining_packages_after=5, idempotency_key=idempotency_key, request_hash=request_hash, operator_id=7, occurred_at=datetime(2026, 7, 26, 11, 30), test_batch_id=test_batch_id, ) class TestUsageBootstrap(unittest.TestCase): def test_usage_service_module_exists(self) -> None: self.assertIsNotNone(SERVICE_SPEC, 'UsageService 尚未实现') def test_usage_service_class_exists(self) -> None: self.assertIsNotNone(UsageService, 'UsageService 尚未实现') def test_usage_controller_module_exists(self) -> None: self.assertIsNotNone(CONTROLLER_SPEC, 'usage_controller 尚未实现') def test_usage_controller_router_exists(self) -> None: self.assertIsNotNone(usage_controller, 'usage_controller 路由尚未实现') @unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现') class TestUsagePublicContract(unittest.TestCase): def test_public_business_methods_require_explicit_test_scope(self) -> None: for method_name in ( 'get_usage_list_services', 'usage_detail_services', 'get_line_options_services', 'scan_usage_pallet_services', 'record_usage_services', ): with self.subTest(method=method_name): self.assertIn('test_scope', inspect.signature(getattr(UsageService, method_name)).parameters) def test_service_exposes_complete_usage_workflow(self) -> None: expected = { 'get_usage_list_services', 'usage_detail_services', 'get_line_options_services', 'scan_usage_pallet_services', 'record_usage_services', } self.assertEqual(expected, {name for name in expected if hasattr(UsageService, name)}) def test_record_signature_and_types_are_exact(self) -> None: signature = inspect.signature(UsageService.record_usage_services) self.assertEqual(('query_db', 'page_object', 'current_user', 'test_scope'), tuple(signature.parameters)) hints = get_type_hints(UsageService.record_usage_services) self.assertIs(AsyncSession, hints['query_db']) self.assertIs(UsageCreateModel, hints['page_object']) self.assertIs(CurrentUserModel, hints['current_user']) self.assertIs(TestBatchScope, hints['test_scope']) self.assertIs(CrudResponseModel, hints['return']) def test_request_hash_is_stable_normalized_and_excludes_idempotency_key(self) -> None: page = make_page(qr_token=' qr-2 ', idempotency_key='key-a') expected = hashlib.sha256( json.dumps( {'line_id': 4, 'qr_token': 'qr-2', 'used_packages': 3}, sort_keys=True, separators=(',', ':'), ).encode() ).hexdigest() self.assertEqual(expected, UsageService._build_request_hash(page)) self.assertEqual( expected, UsageService._build_request_hash(page.model_copy(update={'idempotency_key': 'key-b'})), ) self.assertNotEqual( expected, UsageService._build_request_hash(page.model_copy(update={'used_packages': 2})), ) def test_create_model_requires_strict_positive_integer_packages(self) -> None: for invalid in (True, 0, -1, 1.5): with self.subTest(invalid=invalid), self.assertRaises(ValidationError): make_page(used_packages=invalid) def test_usage_success_response_model_has_exact_camel_case_contract(self) -> None: operation_vo_module = importlib.import_module('module_business.entity.vo.operation_vo') response_model = getattr(operation_vo_module, 'UsageSuccessResponseModel', None) self.assertIsNotNone(response_model, 'UsageSuccessResponseModel 尚未实现') result = response_model( usageId=61, remainingPackages=5, flowState=PalletFlowState.AT_SITE_PARTIAL, ) self.assertEqual( { 'usageId': 61, 'remainingPackages': 5, 'flowState': PalletFlowState.AT_SITE_PARTIAL, }, result.model_dump(by_alias=True), ) self.assertEqual( {'usage_id', 'remaining_packages', 'flow_state'}, set(response_model.model_fields), ) for invalid_kwargs in ( {'usageId': 0, 'remainingPackages': 5, 'flowState': PalletFlowState.AT_SITE_PARTIAL}, {'usageId': 61, 'remainingPackages': -1, 'flowState': PalletFlowState.AT_SITE_PARTIAL}, {'usageId': 61, 'remainingPackages': True, 'flowState': PalletFlowState.AT_SITE_PARTIAL}, {'usageId': 61, 'remainingPackages': 1.5, 'flowState': PalletFlowState.AT_SITE_PARTIAL}, ): with self.subTest(invalid_kwargs=invalid_kwargs), self.assertRaises(ValidationError): response_model(**invalid_kwargs) @unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现') class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) self.user = make_user() async def test_list_pushes_current_site_scope_and_converts_rows(self) -> None: query = UsagePageQueryModel(pageNum=1, pageSize=10) page = PageModel( rows=[make_usage()], pageNum=1, pageSize=10, total=1, hasNext=False, ) get_list = AsyncMock(return_value=page) with ( patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[11, 9, 9])), patch.object(OperationDao, 'get_usage_list', new=get_list), ): result = await UsageService.get_usage_list_services(self.db, query, self.user, TestBatchScope.formal()) get_list.assert_awaited_once_with( self.db, query, site_ids=[9, 11], scope=TestBatchScope.formal(), is_page=True, ) self.assertEqual(1, result.total) self.assertIsInstance(result.rows[0], UsageModel) async def test_empty_scope_returns_empty_without_global_query_or_total(self) -> None: for is_page in (True, False): with self.subTest(is_page=is_page): get_list = AsyncMock() with ( patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[])), patch.object(OperationDao, 'get_usage_list', new=get_list), ): result = await UsageService.get_usage_list_services( self.db, UsagePageQueryModel(pageNum=2, pageSize=7), self.user, TestBatchScope.formal(), is_page=is_page, ) get_list.assert_not_awaited() if is_page: self.assertEqual([], result.rows) self.assertEqual(0, result.total) self.assertEqual(2, result.page_num) self.assertEqual(7, result.page_size) else: self.assertEqual([], result) async def test_list_rejects_explicit_site_outside_scope_before_dao(self) -> None: get_list = AsyncMock() with ( patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), patch.object(OperationDao, 'get_usage_list', new=get_list), self.assertRaises(PermissionException) as raised, ): await UsageService.get_usage_list_services( self.db, UsagePageQueryModel(siteId=10), self.user, TestBatchScope.formal(), ) self.assertEqual('无权访问该客户现场', raised.exception.message) get_list.assert_not_awaited() async def test_admin_list_is_unscoped(self) -> None: query = UsagePageQueryModel() get_scope = AsyncMock() get_list = AsyncMock(return_value=[]) with ( patch.object(AccessDao, 'list_user_site_ids', new=get_scope), patch.object(OperationDao, 'get_usage_list', new=get_list), ): result = await UsageService.get_usage_list_services( self.db, query, make_user(1), TestBatchScope.formal(), is_page=False, ) get_scope.assert_not_awaited() get_list.assert_awaited_once_with( self.db, query, site_ids=None, scope=TestBatchScope.formal(), is_page=False, ) self.assertEqual([], result) async def test_detail_is_scoped_and_hides_missing_but_admin_gets_precise_error(self) -> None: for user, expected_exception, expected_message, expected_scope in ( (self.user, PermissionException, '无权访问该使用记录', [9]), (make_user(1), ServiceException, '使用记录不存在', None), ): get_detail = AsyncMock(return_value=None) with ( self.subTest(admin=user.user.admin), patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), patch.object(OperationDao, 'get_usage_by_id', new=get_detail), self.assertRaises(expected_exception) as raised, ): await UsageService.usage_detail_services(self.db, 61, user, TestBatchScope.formal()) get_detail.assert_awaited_once_with( self.db, 61, TestBatchScope.formal(), site_ids=expected_scope, ) self.assertEqual(expected_message, raised.exception.message) async def test_line_options_require_site_access_and_only_return_enabled_lines_for_site(self) -> None: rows = [ {'lineId': 6, 'siteId': 9, 'lineCode': 'L-6', 'lineName': '六线', 'status': '0'}, {'lineId': 5, 'siteId': 10, 'lineCode': 'L-5', 'lineName': '五线', 'status': '0'}, {'lineId': 4, 'siteId': 9, 'lineCode': 'L-4', 'lineName': '四线', 'status': '1'}, {'lineId': 3, 'siteId': 9, 'lineCode': 'L-3', 'lineName': '三线', 'status': '0'}, ] access = AsyncMock() list_line = AsyncMock(return_value=rows) with ( patch.object(AccessService, 'assert_site_access', new=access), patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())), patch.object(MasterDao, 'list_line', new=list_line), ): result = await UsageService.get_line_options_services(self.db, 9, self.user, TestBatchScope.formal()) access.assert_awaited_once_with(self.db, self.user, 9) query = list_line.await_args.args[1] self.assertIsInstance(query, LinePageQueryModel) self.assertEqual(9, query.site_id) self.assertEqual('0', query.status) self.assertEqual(TestBatchScope.formal(), list_line.await_args.args[2]) self.assertFalse(list_line.await_args.args[3]) self.assertEqual([3, 6], [option.id for option in result]) self.assertTrue(all(isinstance(option, BusinessOptionModel) for option in result)) async def test_line_options_denial_happens_before_master_query(self) -> None: get_site = AsyncMock() list_line = AsyncMock() with ( patch.object( AccessService, 'assert_site_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场')), ), patch.object(MasterDao, 'get_site', new=get_site), patch.object(MasterDao, 'list_line', new=list_line), self.assertRaises(PermissionException), ): await UsageService.get_line_options_services(self.db, 9, self.user, TestBatchScope.formal()) get_site.assert_not_awaited() list_line.assert_not_awaited() async def test_scan_is_read_only_and_returns_only_usage_action(self) -> None: pallet = make_pallet() inventory = make_inventory( current_line_id=4, remaining_packages=5, flow_state=PalletFlowState.AT_SITE_PARTIAL.value, ) filling = make_filling() access = AsyncMock() inventory_lock = AsyncMock() pallet_lock = AsyncMock() filling_lock = AsyncMock() get_filling = AsyncMock(return_value=filling) with ( 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(AccessService, 'assert_site_access', new=access), patch.object(OperationDao, 'get_filling_by_id', new=get_filling), patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock), patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock), patch.object(OperationDao, 'get_active_filling_for_update', new=filling_lock), ): result = await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, TestBatchScope.formal()) access.assert_awaited_once_with(self.db, self.user, 9) get_filling.assert_awaited_once_with(self.db, 31, TestBatchScope.formal()) inventory_lock.assert_not_awaited() pallet_lock.assert_not_awaited() filling_lock.assert_not_awaited() self.db.commit.assert_not_awaited() self.assertIsInstance(result, PalletScanResponseModel) self.assertEqual(PalletFlowState.AT_SITE_PARTIAL, result.flow_state) self.assertEqual(9, result.site_id) self.assertEqual(4, result.current_line_id) self.assertEqual(31, result.active_filling_id) self.assertEqual('S-011', result.strain_code) self.assertEqual('B-20260726', result.batch_no) self.assertEqual(5, result.remaining_packages) self.assertEqual(['usage'], result.allowed_actions) async def test_test_scope_scan_rejects_cross_batch_pallet(self) -> None: scope = make_test_scope(4) with ( patch.object( InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet(test_batch_id=5)), ), patch.object( MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory(test_batch_id=4)), ), patch.object(AccessService, 'assert_site_access', new=AsyncMock()), patch.object( OperationDao, 'get_filling_by_id', new=AsyncMock(return_value=make_filling(test_batch_id=4)), ), self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'), ): await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, scope) async def test_scan_hides_missing_non_site_and_unauthorized_qr_for_normal_user(self) -> None: with ( patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), self.assertRaises(PermissionException) as missing, ): await UsageService.scan_usage_pallet_services(self.db, 'missing', self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', missing.exception.message) with ( patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())), patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory(site_id=None))), self.assertRaises(PermissionException) as non_site, ): await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', non_site.exception.message) with ( patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())), patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory(site_id=10))), patch.object( AccessService, 'assert_site_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场')), ), self.assertRaises(PermissionException) as unauthorized, ): await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', unauthorized.exception.message) async def test_scan_admin_keeps_precise_missing_error(self) -> None: with ( patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException) as raised, ): await UsageService.scan_usage_pallet_services(self.db, 'missing', make_user(1), TestBatchScope.formal()) self.assertEqual('托盘不存在', raised.exception.message) @unittest.skipUnless(usage_controller is not None, 'usage_controller 尚未实现') class TestUsageControllerContract(unittest.TestCase): @staticmethod def _routes() -> dict[tuple[str, str], APIRoute]: return { (route.path, next(iter(route.methods))): route for route in usage_controller.routes if isinstance(route, APIRoute) } def test_openapi_has_exact_five_operations_aliases_and_response_models(self) -> None: app = FastAPI() app.include_router(usage_controller) schema = app.openapi() expected = { ('/business/usage/list', 'get'), ('/business/usage/line-options', 'get'), ('/business/usage/scan/{qr_token}', 'get'), ('/business/usage/{usage_id}', 'get'), ('/business/usage', 'post'), } self.assertEqual(expected, {(path, method) for path, methods in schema['paths'].items() for method in methods}) self.assertEqual( {'siteId'}, {parameter['name'] for parameter in schema['paths']['/business/usage/line-options']['get']['parameters']}, ) self.assertEqual( {'qr_token'}, { parameter['name'] for parameter in schema['paths']['/business/usage/scan/{qr_token}']['get']['parameters'] }, ) routes = self._routes() self.assertEqual(PageResponseModel[UsageModel], routes[('/business/usage/list', 'GET')].response_model) self.assertEqual( DataResponseModel[list[BusinessOptionModel]], routes[('/business/usage/line-options', 'GET')].response_model, ) self.assertEqual( DataResponseModel[PalletScanResponseModel], routes[('/business/usage/scan/{qr_token}', 'GET')].response_model, ) self.assertEqual( DataResponseModel[UsageModel], routes[('/business/usage/{usage_id}', 'GET')].response_model, ) self.assertEqual( DataResponseModel[UsageSuccessResponseModel], routes[('/business/usage', 'POST')].response_model, ) post_response_schema = schema['paths']['/business/usage']['post']['responses']['200']['content'][ 'application/json' ]['schema'] response_schema = schema['components']['schemas'][post_response_schema['$ref'].rsplit('/', 1)[-1]] self.assertIn('data', response_schema['properties']) self.assertIn('data', response_schema['required']) data_schema = schema['components']['schemas'][response_schema['properties']['data']['$ref'].rsplit('/', 1)[-1]] self.assertEqual( {'usageId', 'remainingPackages', 'flowState'}, set(data_schema['properties']), ) def test_static_routes_precede_dynamic_detail(self) -> None: paths = [route.path for route in usage_controller.routes] detail_index = paths.index('/business/usage/{usage_id}') self.assertLess(paths.index('/business/usage/line-options'), detail_index) self.assertLess(paths.index('/business/usage/scan/{qr_token}'), detail_index) def test_routes_have_exact_three_permissions_current_user_and_only_post_log(self) -> None: expected = { ('/business/usage/list', 'GET'): 'business:usage:list', ('/business/usage/line-options', 'GET'): 'business:usage:query', ('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query', ('/business/usage/{usage_id}', 'GET'): 'business:usage:query', ('/business/usage', 'POST'): 'business:usage:add', } 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, ) self.assertEqual(key == ('/business/usage', 'POST'), hasattr(route.endpoint, '__wrapped__')) actual[key] = permissions[0].perm self.assertEqual(expected, actual) self.assertEqual( {'business:usage:list', 'business:usage:query', 'business:usage:add'}, set(actual.values()), ) def test_controller_does_not_add_forbidden_permissions(self) -> None: source = inspect.getsource(usage_controller_module) for forbidden in ( 'business:usage:scan', 'business:usage:export', 'business:usage:edit', 'business:usage:remove', 'business:usage:confirm', ): self.assertNotIn(forbidden, source) @unittest.skipUnless(usage_controller is not None, 'usage_controller 尚未实现') class TestUsageControllerResponse(unittest.IsolatedAsyncioTestCase): async def test_post_returns_service_result_in_data(self) -> None: db = MagicMock(spec=AsyncSession) user = make_user() page = make_page() test_scope = make_test_scope(9) service = AsyncMock( return_value=CrudResponseModel( is_success=True, message='使用成功', result=UsageSuccessResponseModel( usageId=61, remainingPackages=5, flowState=PalletFlowState.AT_SITE_PARTIAL, ), ) ) with patch.object(UsageService, 'record_usage_services', new=service): response = await usage_controller_module.add_business_usage.__wrapped__( request=MagicMock(), add_usage=page, query_db=db, current_user=user, test_scope=test_scope, ) service.assert_awaited_once_with(db, page, user, test_scope) payload = json.loads(response.body) self.assertEqual('使用成功', payload['msg']) self.assertIn('data', payload) self.assertEqual( { 'usageId': 61, 'remainingPackages': 5, 'flowState': PalletFlowState.AT_SITE_PARTIAL.value, }, payload['data'], ) @unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现') class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) self.user = make_user() self.page = make_page() self.pallet = make_pallet() self.inventory = make_inventory() self.filling = make_filling() self.site = make_site() self.line = make_line() self.fixed_time = datetime(2026, 7, 26, 11, 30) def _patch_record_path( # noqa: PLR0913 self, *, site_scope: list[int] | None | AsyncMock = None, key_read: BizUsageRecord | None | AsyncMock = None, scanned_pallet: Any | None = None, pallet: BizPallet | None = None, inventory: BizPalletInventory | None = None, filling: BizFillingRecord | None = None, site: BizCustomerSite | None = None, line: BizProductionLine | None = None, access: AsyncMock | None = None, add_usage: AsyncMock | None = None, update_filling: AsyncMock | None = None, add_event: AsyncMock | None = None, ) -> tuple[ExitStack, dict[str, AsyncMock]]: stack = ExitStack() if isinstance(site_scope, AsyncMock): scope_read = site_scope else: scope_read = AsyncMock(return_value=[9] if site_scope is None else site_scope) usage_key_read = key_read if isinstance(key_read, AsyncMock) else AsyncMock(return_value=key_read) selected_pallet = pallet or self.pallet selected_scanned_pallet = selected_pallet if scanned_pallet is None else scanned_pallet selected_inventory = inventory or self.inventory selected_filling = filling or self.filling selected_site = self.site if site is None else site selected_line = self.line if line is None else line site_access = access or AsyncMock() async def assign_usage_id(_db: AsyncSession, usage: BizUsageRecord) -> BizUsageRecord: usage.usage_id = 61 return usage usage_write = add_usage or AsyncMock(side_effect=assign_usage_id) filling_write = update_filling or AsyncMock() event_write = add_event or AsyncMock() scan_pallet = AsyncMock(return_value=selected_scanned_pallet) inventory_lock = AsyncMock(return_value=selected_inventory) pallet_lock = AsyncMock(return_value=[selected_pallet]) filling_lock = AsyncMock(return_value=selected_filling) site_lock = AsyncMock(return_value=selected_site) preflight_line_read = AsyncMock(return_value=selected_line) line_lock = AsyncMock(return_value=selected_line) async def read_line( db: AsyncSession, line_id: int, scope: TestBatchScope, *, for_update: bool = False, ) -> BizProductionLine | None: if for_update: return await line_lock(db, line_id, scope, for_update=True) return await preflight_line_read(db, line_id, scope) stack.enter_context(patch.object(AccessDao, 'list_user_site_ids', new=scope_read)) stack.enter_context(patch.object(AccessService, 'assert_site_access', new=site_access)) stack.enter_context(patch.object(OperationDao, 'get_usage_by_idempotency_key', new=usage_key_read)) stack.enter_context(patch.object(InventoryDao, 'get_pallet_by_qr_token', new=scan_pallet)) stack.enter_context(patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock)) stack.enter_context(patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock)) stack.enter_context(patch.object(OperationDao, 'get_active_filling_for_update', new=filling_lock)) stack.enter_context(patch.object(MasterDao, 'get_site', new=site_lock)) stack.enter_context(patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=read_line))) stack.enter_context(patch.object(OperationDao, 'add_usage_record', new=usage_write)) stack.enter_context(patch.object(OperationDao, 'update_filling_record', new=filling_write)) stack.enter_context(patch.object(InventoryDao, 'add_event', new=event_write)) mocked_datetime = stack.enter_context(patch('module_business.service.usage_service.datetime')) mocked_datetime.now.return_value = self.fixed_time return stack, { 'scope_read': scope_read, 'key_read': usage_key_read, 'scan_pallet': scan_pallet, 'access': site_access, 'inventory_lock': inventory_lock, 'pallet_lock': pallet_lock, 'filling_lock': filling_lock, 'site_lock': site_lock, 'preflight_line_read': preflight_line_read, 'line_lock': line_lock, 'add_usage': usage_write, 'update_filling': filling_write, 'add_event': event_write, } async def test_first_partial_usage_locks_authoritative_rows_then_writes_one_atomic_event( # noqa: PLR0915 self, ) -> None: actions: list[str] = [] captured: dict[str, Any] = {} scope_call_count = 0 key_call_count = 0 async def read_site_scope(*_args: Any) -> list[int]: nonlocal scope_call_count scope_call_count += 1 actions.append('preflight-scope' if scope_call_count == 1 else 'fresh-scope') return [9] async def scan_pallet(*_args: Any) -> BizPallet: actions.append('qr-snapshot-read') return self.pallet async def rollback() -> None: actions.append('rollback') async def lock_inventory(*_args: Any) -> BizPalletInventory: actions.append('inventory-lock') return self.inventory async def lock_pallets(*_args: Any) -> list[BizPallet]: actions.append('pallet-lock') return [self.pallet] async def lock_filling(*_args: Any) -> BizFillingRecord: actions.append('filling-lock') return self.filling async def lock_site(*_args: Any, **_kwargs: Any) -> BizCustomerSite: actions.append('site-lock') return self.site async def lock_line(*_args: Any, **_kwargs: Any) -> BizProductionLine: actions.append('line-lock') return self.line async def assert_site_access( _db: AsyncSession, _user: CurrentUserModel, _site_id: int, *, for_update: bool = False, ) -> None: if for_update: actions.append('site-mapping-lock') else: actions.append('site-access') async def get_usage_by_key( *_args: Any, **_kwargs: Any, ) -> None: nonlocal key_call_count key_call_count += 1 actions.append('usage-preflight-read' if key_call_count == 1 else 'usage-fresh-read') async def add_usage(_db: AsyncSession, usage: BizUsageRecord) -> BizUsageRecord: actions.append('usage-flush') usage.usage_id = 61 captured['usage'] = usage return usage async def add_event( _db: AsyncSession, inventory: BizPalletInventory, event: BizPalletEvent, _scope: TestBatchScope, ) -> BizPalletEvent: actions.append('event-flush') event.event_id = 91 event.event_seq = 6 inventory.last_event_id = 91 captured['event'] = event return event async def commit() -> None: actions.append('commit') captured['usage'].__dict__.pop('usage_id', None) self.db.rollback.side_effect = rollback self.db.commit.side_effect = commit scope_read = AsyncMock(side_effect=read_site_scope) key_read = AsyncMock(side_effect=get_usage_by_key) access = AsyncMock(side_effect=assert_site_access) update_filling = AsyncMock() with ( patch.object(AccessDao, 'list_user_site_ids', new=scope_read), patch.object(AccessService, 'assert_site_access', new=access), patch.object( OperationDao, 'get_usage_by_idempotency_key', new=key_read, ), patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(side_effect=scan_pallet)), patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(side_effect=lock_inventory)), patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)), patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(side_effect=lock_filling)), patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=lock_site)), patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=lock_line)), patch.object(OperationDao, 'add_usage_record', new=AsyncMock(side_effect=add_usage)), patch.object(OperationDao, 'update_filling_record', new=update_filling), patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)), patch('module_business.service.usage_service.datetime') as mocked_datetime, ): mocked_datetime.now.return_value = self.fixed_time result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual( [ 'preflight-scope', 'line-lock', 'usage-preflight-read', 'qr-snapshot-read', 'rollback', 'inventory-lock', 'site-access', 'fresh-scope', 'pallet-lock', 'filling-lock', 'usage-fresh-read', 'site-lock', 'site-mapping-lock', 'line-lock', 'usage-flush', 'event-flush', 'commit', ], actions, ) self.assertEqual(2, scope_read.await_count) self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}], [call.kwargs for call in key_read.await_args_list], ) self.assertEqual([False, True], [call.kwargs.get('for_update', False) for call in access.await_args_list]) self.assertEqual([9, 9], [call.args[2] for call in access.await_args_list]) self.assertEqual(4, self.inventory.current_line_id) self.assertEqual(5, self.inventory.remaining_packages) self.assertEqual(PalletFlowState.AT_SITE_PARTIAL.value, self.inventory.flow_state) self.assertEqual(31, self.inventory.active_filling_id) self.assertEqual(4, self.inventory.version) self.assertEqual(self.fixed_time, self.inventory.updated_at) self.assertEqual('ACTIVE', self.filling.active_slot) self.assertIsNone(self.filling.closed_at) update_filling.assert_not_awaited() usage = captured['usage'] self.assertTrue(usage.usage_no.startswith('US-')) self.assertEqual(2, usage.pallet_id) self.assertEqual(31, usage.active_filling_id) self.assertEqual(9, usage.site_id) self.assertEqual(4, usage.line_id) self.assertEqual(3, usage.used_packages) self.assertEqual(5, usage.remaining_packages_after) self.assertEqual('usage-key', usage.idempotency_key) self.assertEqual(UsageService._build_request_hash(self.page), usage.request_hash) self.assertEqual(7, usage.operator_id) self.assertEqual(self.fixed_time, usage.occurred_at) event = captured['event'] self.assertEqual(PalletEventType.USAGE_RECORDED.value, event.event_type) self.assertEqual('USAGE', event.source_type) self.assertEqual(61, event.source_id) self.assertEqual(PalletFlowState.AT_SITE_UNUSED.value, event.before_state) self.assertEqual(PalletFlowState.AT_SITE_PARTIAL.value, event.after_state) self.assertIsNone(event.before_warehouse_id) self.assertIsNone(event.after_warehouse_id) self.assertEqual(9, event.before_site_id) self.assertEqual(9, event.after_site_id) self.assertIsNone(event.before_line_id) self.assertEqual(4, event.after_line_id) self.assertEqual(8, event.before_remaining_packages) self.assertEqual(5, event.after_remaining_packages) self.assertEqual(7, event.operator_id) self.assertEqual(self.fixed_time, event.occurred_at) self.assertEqual('usage-key', event.idempotency_key) self.assertEqual(usage.request_hash, event.request_hash) self.db.commit.assert_awaited_once_with() self.db.rollback.assert_awaited_once_with() self.assertTrue(result.is_success) self.assertIsInstance(result.result, UsageSuccessResponseModel) self.assertEqual( { 'usageId': 61, 'remainingPackages': 5, 'flowState': PalletFlowState.AT_SITE_PARTIAL, }, result.result.model_dump(by_alias=True), ) async def test_zero_remaining_closes_filling_but_keeps_line_until_return(self) -> None: inventory = make_inventory( current_line_id=4, remaining_packages=3, flow_state=PalletFlowState.AT_SITE_PARTIAL.value, ) update_filling = AsyncMock() add_event = AsyncMock() stack, mocks = self._patch_record_path( inventory=inventory, update_filling=update_filling, add_event=add_event, ) with stack: result = await UsageService.record_usage_services( self.db, make_page(used_packages=3), self.user, TestBatchScope.formal(), ) self.assertTrue(result.is_success) self.assertIsInstance(result.result, UsageSuccessResponseModel) self.assertEqual( { 'usageId': 61, 'remainingPackages': 0, 'flowState': PalletFlowState.AT_SITE_EMPTY, }, result.result.model_dump(by_alias=True), ) self.assertEqual(PalletFlowState.AT_SITE_EMPTY.value, inventory.flow_state) self.assertEqual(0, inventory.remaining_packages) self.assertIsNone(inventory.active_filling_id) self.assertEqual(4, inventory.current_line_id) self.assertEqual(4, inventory.version) self.assertIsNone(self.filling.active_slot) self.assertEqual(self.fixed_time, self.filling.closed_at) self.assertEqual('user-7', self.filling.update_by) self.assertEqual(self.fixed_time, self.filling.update_time) update_filling.assert_awaited_once_with(self.db, self.filling) event = add_event.await_args.args[2] self.assertEqual(PalletFlowState.AT_SITE_EMPTY.value, event.after_state) self.assertEqual(0, event.after_remaining_packages) self.assertEqual(4, event.after_line_id) self.db.commit.assert_awaited_once_with() self.db.rollback.assert_awaited_once_with() mocks['add_usage'].assert_awaited_once() async def test_same_line_can_continue_but_different_line_is_rejected(self) -> None: same_inventory = make_inventory( current_line_id=4, flow_state=PalletFlowState.AT_SITE_PARTIAL.value, ) stack, _ = self._patch_record_path(inventory=same_inventory) with stack: result = await UsageService.record_usage_services( self.db, make_page(used_packages=2), self.user, TestBatchScope.formal(), ) self.assertTrue(result.is_success) self.assertEqual(4, same_inventory.current_line_id) self.assertEqual(6, same_inventory.remaining_packages) self.db.reset_mock() other_inventory = make_inventory( current_line_id=5, flow_state=PalletFlowState.AT_SITE_PARTIAL.value, ) stack, mocks = self._patch_record_path(inventory=other_inventory) with stack, self.assertRaises(ServiceException) as raised: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertIn('已绑定其他产线', raised.exception.message) mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_line_must_exist_be_enabled_and_belong_to_locked_site(self) -> None: for line, message in ( (None, '产线不存在'), (make_line(status='1'), '产线已停用'), (make_line(site_id=10), '产线不属于托盘当前客户现场'), ): with self.subTest(message=message): self.db.reset_mock() stack, mocks = self._patch_record_path(line=line) if line is None: mocks['preflight_line_read'].return_value = None mocks['line_lock'].return_value = None with stack, self.assertRaises(ServiceException) as raised: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(message, raised.exception.message) mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_only_site_usage_states_and_strict_current_quantity_are_allowed(self) -> None: cases = ( ( make_inventory(flow_state=PalletFlowState.AT_SITE_EMPTY.value, remaining_packages=0), self.page, '托盘当前状态不允许使用', ), ( make_inventory(remaining_packages=8), UsageCreateModel.model_construct( qr_token='qr-2', line_id=4, used_packages=True, idempotency_key='usage-key', ), '使用包数必须为正整数', ), (make_inventory(remaining_packages=8), make_page(used_packages=9), '使用包数不能超过当前余量'), ) for inventory, page, message in cases: with self.subTest(message=message): self.db.reset_mock() stack, mocks = self._patch_record_path(inventory=inventory) with stack, self.assertRaises(ServiceException) as raised: await UsageService.record_usage_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual(message, raised.exception.message) mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_initial_actual_site_denial_hides_qr_before_secondary_locks(self) -> None: access = AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场')) stack, mocks = self._patch_record_path(access=access) with stack, self.assertRaises(PermissionException) as raised: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', raised.exception.message) mocks['inventory_lock'].assert_awaited_once() mocks['pallet_lock'].assert_not_awaited() mocks['filling_lock'].assert_not_awaited() mocks['site_lock'].assert_not_awaited() mocks['line_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_current_mapping_denial_wins_over_disabled_site_without_leaking_status(self) -> None: async def access_check( _db: AsyncSession, _user: CurrentUserModel, _site_id: int, *, for_update: bool = False, ) -> None: if for_update: raise PermissionException(data='', message='无权访问该客户现场') access = AsyncMock(side_effect=access_check) stack, mocks = self._patch_record_path(site=make_site(status='1'), access=access) with stack, self.assertRaises(PermissionException) as raised: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', raised.exception.message) mocks['site_lock'].assert_awaited_once_with( self.db, 9, TestBatchScope.formal(), for_update=True, ) access.assert_awaited_with(self.db, self.user, 9, for_update=True) mocks['line_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_admin_bypasses_mapping_but_gets_precise_site_errors(self) -> None: for site, message in ((None, '客户现场不存在'), (make_site(status='1'), '客户现场已停用')): with self.subTest(message=message): self.db.reset_mock() stack, mocks = self._patch_record_path(site=site) if site is None: mocks['site_lock'].return_value = None with stack, self.assertRaises(ServiceException) as raised: await UsageService.record_usage_services(self.db, self.page, make_user(1), TestBatchScope.formal()) self.assertEqual(message, raised.exception.message) mocks['add_usage'].assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_locked_qr_asset_and_active_filling_invariants_fail_before_usage_write(self) -> None: cases = ( {'pallet': make_pallet(qr_token='other-qr')}, {'pallet': make_pallet(asset_status='1')}, {'inventory': make_inventory(active_filling_id=None)}, {'filling': make_filling(pallet_id=3)}, {'filling': make_filling(active_slot=None)}, ) for overrides in cases: with self.subTest(overrides=tuple(overrides)): self.db.reset_mock() stack, mocks = self._patch_record_path(**overrides) with stack, self.assertRaises(ServiceException): await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) mocks['add_usage'].assert_not_awaited() mocks['update_filling'].assert_not_awaited() mocks['add_event'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_invalid_locked_inventory_version_fails_before_usage_write(self) -> None: inventory = make_inventory(version=-1) stack, mocks = self._patch_record_path(inventory=inventory) with stack, self.assertRaises(ServiceException) as raised: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('托盘库存版本无效', raised.exception.message) mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_record_hides_missing_and_non_site_qr_but_admin_gets_precise_error(self) -> None: with ( patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())), patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)), patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), self.assertRaises(PermissionException) as normal_missing, ): await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', normal_missing.exception.message) self.db.rollback.assert_awaited_once() self.db.reset_mock() with ( patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())), patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)), patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException) as admin_missing, ): await UsageService.record_usage_services(self.db, self.page, make_user(1), TestBatchScope.formal()) self.assertEqual('托盘不存在', admin_missing.exception.message) self.db.rollback.assert_awaited_once() self.db.reset_mock() stack, mocks = self._patch_record_path(inventory=make_inventory(site_id=None)) with stack, self.assertRaises(PermissionException) as non_site: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', non_site.exception.message) mocks['pallet_lock'].assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) @unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现') class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) self.user = make_user() self.page = make_page() self.core = TestRecordUsageCore(methodName='runTest') self.core.setUp() self.core.db = self.db self.core.user = self.user self.core.page = self.page def _exact_usage(self, *, request_hash: str | None = None) -> BizUsageRecord: return make_usage( request_hash=request_hash or UsageService._build_request_hash(self.page), ) def _patch_concurrent_retry_path( self, *, inventory: BizPalletInventory, recovered: BizUsageRecord, scope_sequence: tuple[list[int], list[int]] | None = None, site_status: str = '0', revoke_mapping: bool = False, revoke_initial_access: bool = False, ) -> tuple[ExitStack, dict[str, AsyncMock], list[str]]: actions: list[str] = [] locked_site = make_site(status=site_status) scopes = scope_sequence or ([9], [9]) scope_call_count = 0 key_call_count = 0 rollback_count = 0 async def read_site_scope(*_args: Any) -> list[int]: nonlocal scope_call_count scope_call_count += 1 actions.append('preflight-scope' if scope_call_count == 1 else 'fresh-scope') return scopes[scope_call_count - 1] async def read_key(*_args: Any, **kwargs: Any) -> BizUsageRecord | None: nonlocal key_call_count key_call_count += 1 is_locked_phase = 'inventory-lock' in actions actions.append('usage-fresh-read' if is_locked_phase else 'usage-preflight-read') if not is_locked_phase: return None return recovered if kwargs.get('site_ids') == [inventory.site_id] else None async def scan_pallet(*_args: Any) -> BizPallet: actions.append('qr-snapshot-read') return self.core.pallet async def rollback() -> None: nonlocal rollback_count rollback_count += 1 actions.append('preflight-rollback' if rollback_count == 1 else 'result-rollback') async def lock_inventory(*_args: Any) -> BizPalletInventory: actions.append('inventory-lock') return inventory async def lock_pallet(*_args: Any) -> list[BizPallet]: actions.append('pallet-lock') return [self.core.pallet] async def lock_filling(*_args: Any) -> BizFillingRecord: actions.append('filling-lock') return self.core.filling async def lock_site(*_args: Any, **_kwargs: Any) -> BizCustomerSite: actions.append('site-lock') return locked_site async def check_access(*_args: Any, **kwargs: Any) -> None: if kwargs.get('for_update', False): actions.append('site-mapping-lock') if revoke_mapping: raise PermissionException(data='', message='无权访问该客户现场') else: actions.append('site-access') if revoke_initial_access: raise PermissionException(data='', message='无权访问该客户现场') stack, mocks = self.core._patch_record_path( inventory=inventory, site=locked_site, site_scope=AsyncMock(side_effect=read_site_scope), key_read=AsyncMock(side_effect=read_key), access=AsyncMock(side_effect=check_access), ) mocks['scan_pallet'].side_effect = scan_pallet mocks['inventory_lock'].side_effect = lock_inventory mocks['pallet_lock'].side_effect = lock_pallet mocks['filling_lock'].side_effect = lock_filling mocks['site_lock'].side_effect = lock_site self.db.rollback.side_effect = rollback return stack, mocks, actions async def test_exact_retry_rechecks_current_mapping_and_returns_without_mutation(self) -> None: existing = self._exact_usage() inventory_lock = AsyncMock() site_lock = AsyncMock(return_value=make_site(status='1')) access = AsyncMock() with ( patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())), patch.object( OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=existing), ) as get_key, patch.object(MasterDao, 'get_site', new=site_lock), patch.object(AccessService, 'assert_site_access', new=access), patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock), patch.object(OperationDao, 'add_usage_record', new=AsyncMock()) as add_usage, ): result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) get_key.assert_awaited_once_with( self.db, 'usage-key', TestBatchScope.formal(), site_ids=[9], ) site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True) access.assert_awaited_once_with(self.db, self.user, 9, for_update=True) inventory_lock.assert_not_awaited() add_usage.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() self.assertIsInstance(result.result, UsageSuccessResponseModel) self.assertEqual( { 'usageId': 61, 'remainingPackages': 5, 'flowState': PalletFlowState.AT_SITE_PARTIAL, }, result.result.model_dump(by_alias=True), ) async def test_preflight_key_lookup_uses_line_site_for_multi_scope_and_admin(self) -> None: for user, scope in ((self.user, [10, 9, 10]), (make_user(1), None)): with self.subTest(admin=bool(user.user and user.user.admin)): self.db.reset_mock() existing = self._exact_usage() line_read = AsyncMock(return_value=make_line(site_id=9)) get_key = AsyncMock(return_value=existing) scope_read = AsyncMock(return_value=scope) with ( patch.object(AccessDao, 'list_user_site_ids', new=scope_read), patch.object(MasterDao, 'get_line', new=line_read), patch.object(OperationDao, 'get_usage_by_idempotency_key', new=get_key), patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())), patch.object(AccessService, 'assert_site_access', new=AsyncMock()), ): result = await UsageService.record_usage_services(self.db, self.page, user, TestBatchScope.formal()) line_read.assert_awaited_once_with(self.db, 4, TestBatchScope.formal()) get_key.assert_awaited_once_with( self.db, 'usage-key', TestBatchScope.formal(), site_ids=[9], ) self.assertEqual(61, result.result.usage_id) if user.user and user.user.admin: scope_read.assert_not_awaited() async def test_same_key_with_different_hash_is_stable_conflict(self) -> None: existing = self._exact_usage(request_hash='different-hash') with ( patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())), patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=existing)), patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock()) as inventory_lock, self.assertRaises(ServiceException) as raised, ): await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertIn('幂等键', raised.exception.message) inventory_lock.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_exact_retry_never_reuses_success_after_current_mapping_revocation(self) -> None: existing = self._exact_usage() access = AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场')) with ( patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())), patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=existing)), patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())), patch.object(AccessService, 'assert_site_access', new=access), self.assertRaises(PermissionException), ): await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) access.assert_awaited_once_with(self.db, self.user, 9, for_update=True) self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() async def test_concurrent_exact_retry_wins_before_stale_inventory_rejection(self) -> None: cases = ( ( 'first-request-emptied-pallet', make_inventory( current_line_id=4, active_filling_id=None, remaining_packages=0, flow_state=PalletFlowState.AT_SITE_EMPTY.value, version=4, ), False, 0, '1', ), ( 'first-request-left-less-than-original-usage', make_inventory( current_line_id=4, remaining_packages=2, flow_state=PalletFlowState.AT_SITE_PARTIAL.value, version=4, ), True, 2, '0', ), ) for name, inventory, expect_filling_lock, remaining_after, site_status in cases: with self.subTest(case=name): self.db.reset_mock() recovered = self._exact_usage() recovered.remaining_packages_after = remaining_after stack, mocks, actions = self._patch_concurrent_retry_path( inventory=inventory, recovered=recovered, site_status=site_status, ) key_read = mocks['key_read'] inventory_before = ( inventory.active_filling_id, inventory.current_line_id, inventory.remaining_packages, inventory.flow_state, inventory.version, ) with stack: try: result = await UsageService.record_usage_services( self.db, self.page, self.user, TestBatchScope.formal(), ) except ServiceException as exc: self.fail(f'精确并发重试被旧库存状态提前拒绝:{exc.message}') expected_actions = [ 'preflight-scope', 'usage-preflight-read', 'qr-snapshot-read', 'preflight-rollback', 'inventory-lock', 'site-access', 'fresh-scope', 'pallet-lock', ] if expect_filling_lock: expected_actions.append('filling-lock') expected_actions.extend( [ 'usage-fresh-read', 'site-lock', 'site-mapping-lock', 'result-rollback', ] ) self.assertEqual(expected_actions, actions) self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}], [call.kwargs for call in key_read.await_args_list], ) self.assertEqual(2, mocks['scope_read'].await_count) self.assertTrue(all(call.kwargs['site_ids'] == [9] for call in key_read.await_args_list)) if expect_filling_lock: mocks['filling_lock'].assert_awaited_once() else: mocks['filling_lock'].assert_not_awaited() mocks['site_lock'].assert_awaited_once_with( self.db, 9, TestBatchScope.formal(), for_update=True, ) mocks['line_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() mocks['update_filling'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) self.assertEqual( inventory_before, ( inventory.active_filling_id, inventory.current_line_id, inventory.remaining_packages, inventory.flow_state, inventory.version, ), ) expected_state = ( PalletFlowState.AT_SITE_EMPTY if remaining_after == 0 else PalletFlowState.AT_SITE_PARTIAL ) self.assertIsInstance(result.result, UsageSuccessResponseModel) self.assertEqual( { 'usageId': 61, 'remainingPackages': remaining_after, 'flowState': expected_state, }, result.result.model_dump(by_alias=True), ) async def test_concurrent_retry_uses_fresh_scope_granted_during_inventory_wait(self) -> None: inventory = make_inventory( current_line_id=4, active_filling_id=None, remaining_packages=0, flow_state=PalletFlowState.AT_SITE_EMPTY.value, version=4, ) recovered = self._exact_usage() stack, mocks, actions = self._patch_concurrent_retry_path( inventory=inventory, recovered=recovered, scope_sequence=([], [9]), ) with stack: try: result = await UsageService.record_usage_services( self.db, self.page, self.user, TestBatchScope.formal() ) except ServiceException as exc: self.fail(f'锁等待期间新增授权后未使用 fresh scope:{exc.message}') self.assertEqual( [ 'preflight-scope', 'qr-snapshot-read', 'preflight-rollback', 'inventory-lock', 'site-access', 'fresh-scope', 'pallet-lock', 'usage-fresh-read', 'site-lock', 'site-mapping-lock', 'result-rollback', ], actions, ) self.assertEqual( [{'site_ids': [9]}], [call.kwargs for call in mocks['key_read'].await_args_list], ) mocks['filling_lock'].assert_not_awaited() mocks['line_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) self.assertIsInstance(result.result, UsageSuccessResponseModel) self.assertEqual( { 'usageId': 61, 'remainingPackages': 5, 'flowState': PalletFlowState.AT_SITE_PARTIAL, }, result.result.model_dump(by_alias=True), ) async def test_preflight_grant_revoked_before_fresh_scope_stays_hidden(self) -> None: recovered = self._exact_usage() stack, mocks, actions = self._patch_concurrent_retry_path( inventory=make_inventory(), recovered=recovered, revoke_initial_access=True, ) with stack: try: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) except PermissionException as exc: self.assertEqual('无权访问该托盘', exc.message) else: self.fail('锁事务开始前已撤权时不得继续 fresh scope 或返回成功') self.assertEqual( [ 'preflight-scope', 'usage-preflight-read', 'qr-snapshot-read', 'preflight-rollback', 'inventory-lock', 'site-access', 'result-rollback', ], actions, ) self.assertEqual(1, mocks['scope_read'].await_count) self.assertEqual(1, mocks['key_read'].await_count) mocks['pallet_lock'].assert_not_awaited() mocks['filling_lock'].assert_not_awaited() mocks['site_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_preflight_rollback_expires_scanned_pallet_and_fresh_lock_rechecks_qr(self) -> None: class ExpiringPalletProbe: def __init__(self, pallet_id: int) -> None: self.expired = False self.pallet_id_reads = 0 self._pallet_id = pallet_id self.test_batch_id = None @property def pallet_id(self) -> int: if self.expired: raise AssertionError('rollback 后禁止读取旧 scanned pallet ID') self.pallet_id_reads += 1 return self._pallet_id def expire(self) -> None: self.expired = True def __getattr__(self, name: str) -> NoReturn: raise AssertionError(f'预检 scanned pallet 只允许读取纯整数 ID:{name}') scanned = ExpiringPalletProbe(pallet_id=2) fresh_locked_pallet = make_pallet(qr_token='fresh-qr-mismatch') scope = AsyncMock(side_effect=[[9], [9]]) key_read = AsyncMock(return_value=None) stack, mocks = self.core._patch_record_path( scanned_pallet=scanned, pallet=fresh_locked_pallet, site_scope=scope, key_read=key_read, ) async def rollback() -> None: scanned.expire() self.db.rollback.side_effect = rollback with stack, self.assertRaises(ServiceException) as raised: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('托盘二维码与库存不一致', raised.exception.message) self.assertTrue(scanned.expired) self.assertEqual(1, scanned.pallet_id_reads) mocks['inventory_lock'].assert_awaited_once_with(self.db, 2, TestBatchScope.formal()) mocks['pallet_lock'].assert_awaited_once_with(self.db, [2], TestBatchScope.formal()) self.assertEqual(2, scope.await_count) self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}], [call.kwargs for call in key_read.await_args_list], ) mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_concurrent_exact_retry_rechecks_current_mapping_before_success(self) -> None: inventory = make_inventory( current_line_id=4, active_filling_id=None, remaining_packages=0, flow_state=PalletFlowState.AT_SITE_EMPTY.value, version=4, ) recovered = self._exact_usage() stack, mocks, _ = self._patch_concurrent_retry_path( inventory=inventory, recovered=recovered, revoke_mapping=True, ) key_read = mocks['key_read'] access = mocks['access'] with stack: try: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) except PermissionException as exc: self.assertEqual('无权访问该使用记录', exc.message) except ServiceException as exc: self.fail(f'撤权并发重试未进入 current mapping 复核:{exc.message}') else: self.fail('current mapping 已撤权时不得返回幂等成功') self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}], [call.kwargs for call in key_read.await_args_list], ) self.assertEqual(2, mocks['scope_read'].await_count) mocks['site_lock'].assert_awaited_once_with( self.db, 9, TestBatchScope.formal(), for_update=True, ) self.assertEqual([False, True], [call.kwargs.get('for_update', False) for call in access.await_args_list]) mocks['line_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() mocks['update_filling'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_concurrent_fresh_key_read_rejects_hash_or_actual_site_conflict(self) -> None: request_hash = UsageService._build_request_hash(self.page) conflicts = ( ('different-request-hash', make_usage(request_hash='different-hash')), ('different-actual-site', make_usage(site_id=10, request_hash=request_hash)), ) for name, recovered in conflicts: with self.subTest(case=name): self.db.reset_mock() inventory = make_inventory( current_line_id=4, active_filling_id=None, remaining_packages=0, flow_state=PalletFlowState.AT_SITE_EMPTY.value, version=4, ) stack, mocks, _ = self._patch_concurrent_retry_path( inventory=inventory, recovered=recovered, scope_sequence=([9, 10], [9, 10]), ) key_read = mocks['key_read'] with stack: try: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) except ServiceException as exc: self.assertEqual( '使用失败,幂等键已被其他请求使用', exc.message, ) else: self.fail('锁后命中的摘要或实际现场冲突不得返回成功') self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}], [call.kwargs for call in key_read.await_args_list], ) self.assertEqual(2, mocks['scope_read'].await_count) self.assertTrue(all(call.kwargs['site_ids'] == [9] for call in key_read.await_args_list)) mocks['site_lock'].assert_not_awaited() mocks['line_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() mocks['update_filling'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) async def test_integrity_error_recovers_only_fresh_scoped_exact_result(self) -> None: recovered = self._exact_usage() scope = AsyncMock(side_effect=[[10, 9], [10, 9], [10, 9]]) key_read = AsyncMock(side_effect=[None, None, recovered]) add_usage = AsyncMock(side_effect=integrity_error()) stack, mocks = self.core._patch_record_path( site_scope=scope, key_read=key_read, add_usage=add_usage, ) with stack: result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertIsInstance(result.result, UsageSuccessResponseModel) self.assertEqual( { 'usageId': 61, 'remainingPackages': 5, 'flowState': PalletFlowState.AT_SITE_PARTIAL, }, result.result.model_dump(by_alias=True), ) self.assertEqual(3, scope.await_count) self.assertEqual(3, key_read.await_count) self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}, {'site_ids': [9]}], [call.kwargs for call in key_read.await_args_list], ) self.assertEqual([9], key_read.await_args_list[2].kwargs['site_ids']) self.assertEqual(3, self.db.rollback.await_count) self.db.commit.assert_not_awaited() mocks['add_event'].assert_not_awaited() async def test_integrity_recovery_rejects_different_hash_or_missing_result(self) -> None: for recovered in (self._exact_usage(request_hash='different-hash'), None): with self.subTest(recovered=recovered is not None): self.db.reset_mock() key_read = AsyncMock(side_effect=[None, None, recovered]) stack, _ = self.core._patch_record_path( key_read=key_read, add_usage=AsyncMock(side_effect=integrity_error()), ) with stack, self.assertRaises(ServiceException) as raised: await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertTrue('幂等' in raised.exception.message or '冲突' in raised.exception.message) self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}, {'site_ids': [9]}], [call.kwargs for call in key_read.await_args_list], ) self.assertEqual(3, self.db.rollback.await_count) self.db.commit.assert_not_awaited() async def test_integrity_recovery_rechecks_fresh_scope_and_rejects_revoked_site(self) -> None: scope = AsyncMock(side_effect=[[9], [9], []]) key_read = AsyncMock(return_value=None) stack, mocks = self.core._patch_record_path( site_scope=scope, key_read=key_read, add_usage=AsyncMock(side_effect=integrity_error()), ) with stack, self.assertRaises(PermissionException): await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(3, scope.await_count) self.assertEqual(2, key_read.await_count) self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}], [call.kwargs for call in key_read.await_args_list], ) self.assertEqual(3, self.db.rollback.await_count) mocks['add_event'].assert_not_awaited() self.db.commit.assert_not_awaited() async def test_unexpected_write_failure_rolls_back_lock_transaction_and_never_commits(self) -> None: stack, _ = self.core._patch_record_path( add_event=AsyncMock(side_effect=RuntimeError('event failed')), ) with stack, self.assertRaises(RuntimeError): await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(2, self.db.rollback.await_count) self.db.commit.assert_not_awaited() async def test_request_hash_failure_also_rolls_back(self) -> None: with ( patch.object(UsageService, '_build_request_hash', side_effect=RuntimeError('hash failed')), self.assertRaises(RuntimeError), ): await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.db.rollback.assert_awaited_once() self.db.commit.assert_not_awaited() if __name__ == '__main__': unittest.main()