From e5006c6e43bf4b49e20a46c3896b568645df21e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Mon, 27 Jul 2026 06:27:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=90=8E=E7=9A=84=E4=BD=99=E9=87=8F=E4=B8=8E?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/usage_controller.py | 7 +- .../module_business/entity/vo/operation_vo.py | 10 ++ .../module_business/service/usage_service.py | 30 +++- .../module_business/test_usage_service.py | 155 +++++++++++++++++- 4 files changed, 184 insertions(+), 18 deletions(-) diff --git a/shierfeng-fastapi-backend/module_business/controller/usage_controller.py b/shierfeng-fastapi-backend/module_business/controller/usage_controller.py index a010bb9..3b1608c 100644 --- a/shierfeng-fastapi-backend/module_business/controller/usage_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/usage_controller.py @@ -11,7 +11,7 @@ from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.enums import BusinessType from common.router import APIRouterPro -from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel +from common.vo import DataResponseModel, PageResponseModel from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.entity.vo.operation_vo import ( BusinessOptionModel, @@ -19,6 +19,7 @@ from module_business.entity.vo.operation_vo import ( UsageCreateModel, UsageModel, UsagePageQueryModel, + UsageSuccessResponseModel, ) from module_business.service.usage_service import UsageService from utils.response_util import ResponseUtil @@ -110,7 +111,7 @@ async def query_detail_business_usage( '', summary='记录客户现场使用接口', description='扫描托盘并原子记录客户现场使用包数', - response_model=ResponseBaseModel, + response_model=DataResponseModel[UsageSuccessResponseModel], dependencies=[UserInterfaceAuthDependency('business:usage:add')], ) @Log(title='客户现场使用', business_type=BusinessType.INSERT) @@ -121,4 +122,4 @@ async def add_business_usage( current_user: Annotated[CurrentUserModel, CurrentUserDependency()], ) -> Response: result = await UsageService.record_usage_services(query_db, add_usage, current_user) - return ResponseUtil.success(msg=result.message) + return ResponseUtil.success(msg=result.message, data=result.result) diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py index 839a3fc..ef8dc4e 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py @@ -154,6 +154,16 @@ class UsageCreateModel(BaseModel): idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') +class UsageSuccessResponseModel(BaseModel): + """客户现场使用成功响应模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + usage_id: int = Field(gt=0, description='使用记录ID') + remaining_packages: int = Field(strict=True, ge=0, description='使用后剩余包数') + flow_state: PalletFlowState = Field(description='使用后托盘流转状态') + + class ReturnRequestModel(BaseModel): """托盘退回申请命令模型。""" diff --git a/shierfeng-fastapi-backend/module_business/service/usage_service.py b/shierfeng-fastapi-backend/module_business/service/usage_service.py index 2862375..efb6014 100644 --- a/shierfeng-fastapi-backend/module_business/service/usage_service.py +++ b/shierfeng-fastapi-backend/module_business/service/usage_service.py @@ -26,6 +26,7 @@ from module_business.entity.vo.operation_vo import ( UsageCreateModel, UsageModel, UsagePageQueryModel, + UsageSuccessResponseModel, ) from module_business.service.access_service import AccessService @@ -127,8 +128,20 @@ class UsageService: return usage.idempotency_key == page_object.idempotency_key and usage.request_hash == request_hash @staticmethod - def _success(usage_id: int) -> CrudResponseModel: - return CrudResponseModel(is_success=True, message='使用成功', result=usage_id) + def _success(usage: BizUsageRecord) -> CrudResponseModel: + remaining_packages = usage.remaining_packages_after + flow_state = ( + PalletFlowState.AT_SITE_EMPTY if remaining_packages == 0 else PalletFlowState.AT_SITE_PARTIAL + ) + return CrudResponseModel( + is_success=True, + message='使用成功', + result=UsageSuccessResponseModel( + usage_id=usage.usage_id, + remaining_packages=remaining_packages, + flow_state=flow_state, + ), + ) @classmethod async def _lock_and_reauthorize_usage_site( @@ -181,7 +194,7 @@ class UsageService: recovered.site_id, current_user, ) - return cls._success(recovered.usage_id) + return cls._success(recovered) finally: await query_db.rollback() @@ -355,9 +368,9 @@ class UsageService: existing.site_id, current_user, ) - usage_id = existing.usage_id + result = cls._success(existing) await query_db.rollback() - return cls._success(usage_id) + return result scanned_pallet = await InventoryDao.get_pallet_by_qr_token( query_db, @@ -413,9 +426,9 @@ class UsageService: locked_existing.site_id, current_user, ) - usage_id = locked_existing.usage_id + result = cls._success(locked_existing) await query_db.rollback() - return cls._success(usage_id) + return result if inventory.active_filling_id is None: raise ServiceException(message='托盘不存在活动装填周期') @@ -495,6 +508,7 @@ class UsageService: ) usage = await OperationDao.add_usage_record(query_db, usage) usage_id = usage.usage_id + result = cls._success(usage) before_state = inventory.flow_state before_warehouse_id = inventory.warehouse_id @@ -535,7 +549,7 @@ class UsageService: ) await InventoryDao.add_event(query_db, inventory, event) await query_db.commit() - return cls._success(usage_id) + return result except IntegrityError: await query_db.rollback() if expected_site_id is None: diff --git a/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py b/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py index 885c5de..31d567f 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py @@ -22,7 +22,6 @@ from common.vo import ( DataResponseModel, PageModel, PageResponseModel, - ResponseBaseModel, ) from exceptions.exception import PermissionException, ServiceException from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel @@ -46,6 +45,7 @@ from module_business.entity.vo.operation_vo import ( UsageCreateModel, UsageModel, UsagePageQueryModel, + UsageSuccessResponseModel, ) from module_business.service.access_service import AccessService @@ -267,6 +267,37 @@ class TestUsagePublicContract(unittest.TestCase): 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): @@ -539,7 +570,24 @@ class TestUsageControllerContract(unittest.TestCase): DataResponseModel[UsageModel], routes[('/business/usage/{usage_id}', 'GET')].response_model, ) - self.assertEqual(ResponseBaseModel, routes[('/business/usage', 'POST')].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] @@ -592,6 +640,45 @@ class TestUsageControllerContract(unittest.TestCase): 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() + 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, + ) + + service.assert_awaited_once_with(db, page, user) + 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: @@ -861,7 +948,15 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): self.db.commit.assert_awaited_once_with() self.db.rollback.assert_awaited_once_with() self.assertTrue(result.is_success) - self.assertEqual(61, result.result) + 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( @@ -884,6 +979,15 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): ) 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) @@ -1222,7 +1326,15 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): add_usage.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() - self.assertEqual(61, result.result) + 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_same_key_with_different_hash_is_stable_conflict(self) -> None: existing = self._exact_usage(request_hash='different-hash') @@ -1359,7 +1471,20 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): inventory.version, ), ) - self.assertEqual(61, result.result) + 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( @@ -1408,7 +1533,15 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): mocks['add_event'].assert_not_awaited() self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) - self.assertEqual(61, result.result) + 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() @@ -1599,7 +1732,15 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): with stack: result = await UsageService.record_usage_services(self.db, self.page, self.user) - self.assertEqual(61, result.result) + 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(