修复:支持已确认退回的并发幂等
This commit is contained in:
@@ -696,16 +696,18 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
key_read = AsyncMock(side_effect=read_key)
|
||||
scope_read = AsyncMock(side_effect=read_scope)
|
||||
pallet_locks = AsyncMock(side_effect=lock_pallets)
|
||||
filling_locks = AsyncMock(side_effect=lock_filling)
|
||||
self.db.rollback.side_effect = rollback
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=scope_read),
|
||||
patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=key_read),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(side_effect=lock_inventories)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_locks),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_active_filling_for_update',
|
||||
new=AsyncMock(side_effect=lock_filling),
|
||||
new=filling_locks,
|
||||
),
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=check_access)),
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=lock_site)),
|
||||
@@ -728,8 +730,6 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
'inventory-lock',
|
||||
'access-fresh',
|
||||
'scope-fresh',
|
||||
'pallet-lock',
|
||||
'filling-lock',
|
||||
'key-fresh',
|
||||
'site-lock',
|
||||
'mapping-lock',
|
||||
@@ -739,6 +739,8 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
self.assertEqual(2, key_read.await_count)
|
||||
self.assertEqual(2, scope_read.await_count)
|
||||
pallet_locks.assert_not_awaited()
|
||||
filling_locks.assert_not_awaited()
|
||||
add_order.assert_not_awaited()
|
||||
add_items.assert_not_awaited()
|
||||
add_event.assert_not_awaited()
|
||||
@@ -779,6 +781,109 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
|
||||
async def test_concurrent_confirmed_winner_returns_before_inventory_business_validation(self) -> None:
|
||||
winner = make_return_order(
|
||||
status=ReturnStatus.CONFIRMED.value,
|
||||
request_hash=ReturnService._build_request_hash(self.page),
|
||||
)
|
||||
relocated = [
|
||||
make_inventory(
|
||||
2,
|
||||
site_id=None,
|
||||
warehouse_id=7,
|
||||
line_id=None,
|
||||
state=PalletFlowState.FILLED_IN_WAREHOUSE.value,
|
||||
),
|
||||
make_inventory(
|
||||
5,
|
||||
site_id=None,
|
||||
warehouse_id=7,
|
||||
line_id=None,
|
||||
remaining=0,
|
||||
filling_id=None,
|
||||
state=PalletFlowState.EMPTY_IN_WAREHOUSE.value,
|
||||
),
|
||||
]
|
||||
key_read = AsyncMock(side_effect=[None, winner])
|
||||
pallet_locks = AsyncMock()
|
||||
filling_locks = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=key_read),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=relocated)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_locks),
|
||||
patch.object(OperationDao, 'get_active_filling_for_update', new=filling_locks),
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
|
||||
patch.object(OperationDao, 'add_return_order', new=AsyncMock()) as add_order,
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock()) as add_event,
|
||||
):
|
||||
try:
|
||||
result = await ReturnService.request_return_services(self.db, self.page, self.user)
|
||||
except PermissionException as exc:
|
||||
self.fail(f'已确认赢家在 fresh key 查询前被库存位置提前隐藏:{exc.message}')
|
||||
|
||||
self.assertEqual(13, result.result)
|
||||
self.assertEqual(2, key_read.await_count)
|
||||
pallet_locks.assert_not_awaited()
|
||||
filling_locks.assert_not_awaited()
|
||||
add_order.assert_not_awaited()
|
||||
add_event.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
|
||||
async def test_concurrent_confirmed_winner_different_payload_is_stable_conflict(self) -> None:
|
||||
winner = make_return_order(
|
||||
status=ReturnStatus.CONFIRMED.value,
|
||||
request_hash=ReturnService._build_request_hash(self.page),
|
||||
)
|
||||
conflict_page = self.page.model_copy(update={'target_warehouse_id': 8})
|
||||
relocated = [
|
||||
make_inventory(
|
||||
2,
|
||||
site_id=None,
|
||||
warehouse_id=7,
|
||||
line_id=None,
|
||||
state=PalletFlowState.FILLED_IN_WAREHOUSE.value,
|
||||
),
|
||||
make_inventory(
|
||||
5,
|
||||
site_id=None,
|
||||
warehouse_id=7,
|
||||
line_id=None,
|
||||
remaining=0,
|
||||
filling_id=None,
|
||||
state=PalletFlowState.EMPTY_IN_WAREHOUSE.value,
|
||||
),
|
||||
]
|
||||
key_read = AsyncMock(side_effect=[None, winner])
|
||||
pallet_locks = AsyncMock()
|
||||
filling_locks = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=key_read),
|
||||
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=relocated)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_locks),
|
||||
patch.object(OperationDao, 'get_active_filling_for_update', new=filling_locks),
|
||||
patch.object(OperationDao, 'add_return_order', new=AsyncMock()) as add_order,
|
||||
):
|
||||
try:
|
||||
await ReturnService.request_return_services(self.db, conflict_page, self.user)
|
||||
except Exception as exc:
|
||||
raised = exc
|
||||
else:
|
||||
raised = None
|
||||
|
||||
self.assertIsInstance(raised, ServiceException)
|
||||
self.assertEqual('申请退回失败,幂等键已被其他请求使用', getattr(raised, 'message', None))
|
||||
self.assertEqual(2, key_read.await_count)
|
||||
pallet_locks.assert_not_awaited()
|
||||
filling_locks.assert_not_awaited()
|
||||
add_order.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
|
||||
async def test_concurrent_winner_uses_fresh_expanded_site_scope(self) -> None:
|
||||
winner = make_return_order(request_hash=ReturnService._build_request_hash(self.page))
|
||||
inventories = [
|
||||
@@ -903,12 +1008,14 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
with self.subTest(case=name):
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
pallet_locks = AsyncMock(return_value=[make_pallet(2), make_pallet(5)])
|
||||
scope_read = AsyncMock(return_value=[9])
|
||||
key_read = AsyncMock(return_value=None)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=scope_read),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_return_by_request_idempotency_key',
|
||||
new=AsyncMock(return_value=None),
|
||||
new=key_read,
|
||||
),
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||||
patch.object(
|
||||
@@ -926,6 +1033,8 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
|
||||
raised = None
|
||||
self.assertIsInstance(raised, PermissionException)
|
||||
messages.append(getattr(raised, 'message', '未抛出异常'))
|
||||
self.assertEqual(2, scope_read.await_count)
|
||||
self.assertEqual(2, key_read.await_count)
|
||||
pallet_locks.assert_not_awaited()
|
||||
db.commit.assert_not_awaited()
|
||||
db.rollback.assert_awaited()
|
||||
|
||||
Reference in New Issue
Block a user