修复:返回使用后的余量与状态
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user