修复:返回使用后的余量与状态

This commit is contained in:
焦龙言
2026-07-27 06:27:29 +08:00
parent d0fa20afe8
commit e5006c6e43
4 changed files with 184 additions and 18 deletions
@@ -11,7 +11,7 @@ from common.aspect.interface_auth import UserInterfaceAuthDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.enums import BusinessType from common.enums import BusinessType
from common.router import APIRouterPro 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_admin.entity.vo.user_vo import CurrentUserModel
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
@@ -19,6 +19,7 @@ from module_business.entity.vo.operation_vo import (
UsageCreateModel, UsageCreateModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSuccessResponseModel,
) )
from module_business.service.usage_service import UsageService from module_business.service.usage_service import UsageService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -110,7 +111,7 @@ async def query_detail_business_usage(
'', '',
summary='记录客户现场使用接口', summary='记录客户现场使用接口',
description='扫描托盘并原子记录客户现场使用包数', description='扫描托盘并原子记录客户现场使用包数',
response_model=ResponseBaseModel, response_model=DataResponseModel[UsageSuccessResponseModel],
dependencies=[UserInterfaceAuthDependency('business:usage:add')], dependencies=[UserInterfaceAuthDependency('business:usage:add')],
) )
@Log(title='客户现场使用', business_type=BusinessType.INSERT) @Log(title='客户现场使用', business_type=BusinessType.INSERT)
@@ -121,4 +122,4 @@ async def add_business_usage(
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response: ) -> Response:
result = await UsageService.record_usage_services(query_db, add_usage, current_user) 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)
@@ -154,6 +154,16 @@ class UsageCreateModel(BaseModel):
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') 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): class ReturnRequestModel(BaseModel):
"""托盘退回申请命令模型。""" """托盘退回申请命令模型。"""
@@ -26,6 +26,7 @@ from module_business.entity.vo.operation_vo import (
UsageCreateModel, UsageCreateModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSuccessResponseModel,
) )
from module_business.service.access_service import AccessService 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 return usage.idempotency_key == page_object.idempotency_key and usage.request_hash == request_hash
@staticmethod @staticmethod
def _success(usage_id: int) -> CrudResponseModel: def _success(usage: BizUsageRecord) -> CrudResponseModel:
return CrudResponseModel(is_success=True, message='使用成功', result=usage_id) 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 @classmethod
async def _lock_and_reauthorize_usage_site( async def _lock_and_reauthorize_usage_site(
@@ -181,7 +194,7 @@ class UsageService:
recovered.site_id, recovered.site_id,
current_user, current_user,
) )
return cls._success(recovered.usage_id) return cls._success(recovered)
finally: finally:
await query_db.rollback() await query_db.rollback()
@@ -355,9 +368,9 @@ class UsageService:
existing.site_id, existing.site_id,
current_user, current_user,
) )
usage_id = existing.usage_id result = cls._success(existing)
await query_db.rollback() await query_db.rollback()
return cls._success(usage_id) return result
scanned_pallet = await InventoryDao.get_pallet_by_qr_token( scanned_pallet = await InventoryDao.get_pallet_by_qr_token(
query_db, query_db,
@@ -413,9 +426,9 @@ class UsageService:
locked_existing.site_id, locked_existing.site_id,
current_user, current_user,
) )
usage_id = locked_existing.usage_id result = cls._success(locked_existing)
await query_db.rollback() await query_db.rollback()
return cls._success(usage_id) return result
if inventory.active_filling_id is None: if inventory.active_filling_id is None:
raise ServiceException(message='托盘不存在活动装填周期') raise ServiceException(message='托盘不存在活动装填周期')
@@ -495,6 +508,7 @@ class UsageService:
) )
usage = await OperationDao.add_usage_record(query_db, usage) usage = await OperationDao.add_usage_record(query_db, usage)
usage_id = usage.usage_id usage_id = usage.usage_id
result = cls._success(usage)
before_state = inventory.flow_state before_state = inventory.flow_state
before_warehouse_id = inventory.warehouse_id before_warehouse_id = inventory.warehouse_id
@@ -535,7 +549,7 @@ class UsageService:
) )
await InventoryDao.add_event(query_db, inventory, event) await InventoryDao.add_event(query_db, inventory, event)
await query_db.commit() await query_db.commit()
return cls._success(usage_id) return result
except IntegrityError: except IntegrityError:
await query_db.rollback() await query_db.rollback()
if expected_site_id is None: if expected_site_id is None:
@@ -22,7 +22,6 @@ from common.vo import (
DataResponseModel, DataResponseModel,
PageModel, PageModel,
PageResponseModel, PageResponseModel,
ResponseBaseModel,
) )
from exceptions.exception import PermissionException, ServiceException from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
@@ -46,6 +45,7 @@ from module_business.entity.vo.operation_vo import (
UsageCreateModel, UsageCreateModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSuccessResponseModel,
) )
from module_business.service.access_service import AccessService from module_business.service.access_service import AccessService
@@ -267,6 +267,37 @@ class TestUsagePublicContract(unittest.TestCase):
with self.subTest(invalid=invalid), self.assertRaises(ValidationError): with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
make_page(used_packages=invalid) 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 尚未实现') @unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现')
class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): class TestUsageReadServices(unittest.IsolatedAsyncioTestCase):
@@ -539,7 +570,24 @@ class TestUsageControllerContract(unittest.TestCase):
DataResponseModel[UsageModel], DataResponseModel[UsageModel],
routes[('/business/usage/{usage_id}', 'GET')].response_model, 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: def test_static_routes_precede_dynamic_detail(self) -> None:
paths = [route.path for route in usage_controller.routes] paths = [route.path for route in usage_controller.routes]
@@ -592,6 +640,45 @@ class TestUsageControllerContract(unittest.TestCase):
self.assertNotIn(forbidden, source) 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 尚未实现') @unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现')
class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -861,7 +948,15 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_awaited_once_with() self.db.commit.assert_awaited_once_with()
self.db.rollback.assert_awaited_once_with() self.db.rollback.assert_awaited_once_with()
self.assertTrue(result.is_success) 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: async def test_zero_remaining_closes_filling_but_keeps_line_until_return(self) -> None:
inventory = make_inventory( inventory = make_inventory(
@@ -884,6 +979,15 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
) )
self.assertTrue(result.is_success) 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(PalletFlowState.AT_SITE_EMPTY.value, inventory.flow_state)
self.assertEqual(0, inventory.remaining_packages) self.assertEqual(0, inventory.remaining_packages)
self.assertIsNone(inventory.active_filling_id) self.assertIsNone(inventory.active_filling_id)
@@ -1222,7 +1326,15 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
add_usage.assert_not_awaited() add_usage.assert_not_awaited()
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once() self.db.rollback.assert_awaited_once()
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: async def test_same_key_with_different_hash_is_stable_conflict(self) -> None:
existing = self._exact_usage(request_hash='different-hash') existing = self._exact_usage(request_hash='different-hash')
@@ -1359,7 +1471,20 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
inventory.version, 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: async def test_concurrent_retry_uses_fresh_scope_granted_during_inventory_wait(self) -> None:
inventory = make_inventory( inventory = make_inventory(
@@ -1408,7 +1533,15 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
mocks['add_event'].assert_not_awaited() mocks['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count) 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: async def test_preflight_grant_revoked_before_fresh_scope_stays_hidden(self) -> None:
recovered = self._exact_usage() recovered = self._exact_usage()
@@ -1599,7 +1732,15 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
with stack: with stack:
result = await UsageService.record_usage_services(self.db, self.page, self.user) 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, scope.await_count)
self.assertEqual(3, key_read.await_count) self.assertEqual(3, key_read.await_count)
self.assertEqual( self.assertEqual(