修复:完善退回并发幂等与信息隐藏

This commit is contained in:
焦龙言
2026-07-27 00:08:57 +08:00
parent e58e110dd1
commit 12260fdf83
2 changed files with 556 additions and 39 deletions
@@ -377,7 +377,7 @@ class ReturnService:
cls._raise_pallet_not_accessible(current_user)
matching_items = [item for item in return_order.items if item.pallet_id == pallet.pallet_id]
if len(matching_items) != 1:
raise ServiceException(message='扫码托盘不属于该退回单')
cls._raise_pallet_not_accessible(current_user, '扫码托盘不属于该退回单')
inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id)
if inventory is None:
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
@@ -445,21 +445,37 @@ class ReturnService:
query_db: AsyncSession,
pallet_ids: list[int],
source_site_id: int,
) -> tuple[list[BizPalletInventory], list[BizPallet], dict[int, BizFillingRecord]]:
current_user: CurrentUserModel,
) -> tuple[
list[BizPalletInventory],
list[BizPallet],
dict[int, BizFillingRecord | None],
list[int] | None,
]:
ordered_ids = cls._validate_pallet_ids(pallet_ids)
inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids)
pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids)
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets}
if set(inventory_by_id) != set(ordered_ids) or set(pallet_by_id) != set(ordered_ids):
raise ServiceException(message='存在不存在或库存缺失的托盘')
if set(inventory_by_id) != set(ordered_ids):
cls._raise_pallet_not_accessible(current_user, '存在不存在或库存缺失的托盘')
ordered_inventories = [inventory_by_id[pallet_id] for pallet_id in ordered_ids]
ordered_pallets = [pallet_by_id[pallet_id] for pallet_id in ordered_ids]
for pallet, inventory in zip(ordered_pallets, ordered_inventories, strict=True):
cls._validate_request_inventory(pallet, inventory, source_site_id)
if any(inventory.site_id != source_site_id for inventory in ordered_inventories):
cls._raise_pallet_not_accessible(current_user, '同一退回单托盘必须属于同一现场')
try:
await AccessService.assert_site_access(query_db, current_user, source_site_id)
except PermissionException:
cls._raise_pallet_not_accessible(current_user)
fresh_site_ids = await cls._site_scope(query_db, current_user)
if fresh_site_ids is not None and source_site_id not in fresh_site_ids:
cls._raise_pallet_not_accessible(current_user)
filling_by_id = {}
pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids)
pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets}
if set(pallet_by_id) != set(ordered_ids):
cls._raise_pallet_not_accessible(current_user, '存在不存在或库存缺失的托盘')
ordered_pallets = [pallet_by_id[pallet_id] for pallet_id in ordered_ids]
filling_by_id: dict[int, BizFillingRecord | None] = {}
filling_ids = sorted(
{
inventory.active_filling_id
@@ -468,14 +484,31 @@ class ReturnService:
}
)
for filling_id in filling_ids:
filling = await OperationDao.get_active_filling_for_update(query_db, filling_id)
if filling is None:
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
filling_by_id[filling_id] = filling
for inventory in ordered_inventories:
filling_by_id[filling_id] = await OperationDao.get_active_filling_for_update(query_db, filling_id)
return ordered_inventories, ordered_pallets, filling_by_id, fresh_site_ids
@classmethod
def _validate_locked_request_pallets(
cls,
inventories: list[BizPalletInventory],
pallets: list[BizPallet],
filling_by_id: dict[int, BizFillingRecord | None],
source_site_id: int,
current_user: CurrentUserModel,
) -> None:
for pallet, inventory in zip(pallets, inventories, strict=True):
cls._validate_request_inventory(pallet, inventory, source_site_id)
for inventory in inventories:
if inventory.active_filling_id is not None:
cls._validate_active_filling(filling_by_id[inventory.active_filling_id], inventory)
return ordered_inventories, ordered_pallets, filling_by_id
filling = filling_by_id[inventory.active_filling_id]
if filling is None:
cls._raise_pallet_not_accessible(current_user, '托盘活动装填周期不存在或已关闭')
try:
cls._validate_active_filling(filling, inventory)
except ServiceException:
if not cls._is_admin(current_user):
cls._raise_pallet_not_accessible(current_user)
raise
@staticmethod
def _build_return_items(
@@ -585,7 +618,7 @@ class ReturnService:
await query_db.rollback()
@classmethod
async def request_return_services(
async def request_return_services( # noqa: PLR0915
cls,
query_db: AsyncSession,
page_object: ReturnRequestModel,
@@ -595,30 +628,34 @@ class ReturnService:
user_id, user_name = cls._current_identity(current_user)
ordered_ids = cls._validate_pallet_ids(page_object.pallet_ids)
request_hash = cls._build_request_hash(page_object)
site_ids = await cls._site_scope(query_db, current_user)
if site_ids is not None and page_object.source_site_id not in site_ids:
raise PermissionException(data='', message='无权访问该客户现场')
preflight_site_ids = await cls._site_scope(query_db, current_user)
if preflight_site_ids is not None and page_object.source_site_id not in preflight_site_ids:
cls._raise_pallet_not_accessible(current_user)
try:
await AccessService.assert_site_access(
query_db,
current_user,
page_object.source_site_id,
)
request_warehouse_ids = None if site_ids is None else []
except PermissionException:
cls._raise_pallet_not_accessible(current_user)
request_warehouse_ids = None if preflight_site_ids is None else []
existing = await OperationDao.get_return_by_request_idempotency_key(
query_db,
page_object.idempotency_key,
site_ids=site_ids,
site_ids=preflight_site_ids,
warehouse_ids=request_warehouse_ids,
)
if existing is not None and not cls._is_exact_request_result(existing, page_object, request_hash):
raise ServiceException(message='申请退回失败,幂等键已被其他请求使用')
preflight_existing_id = int(existing.return_id) if existing is not None else None
await query_db.rollback()
if existing is not None:
if preflight_existing_id is not None:
locked_existing = await OperationDao.get_return_for_update(
query_db,
existing.return_id,
site_ids=site_ids,
preflight_existing_id,
site_ids=preflight_site_ids,
warehouse_ids=request_warehouse_ids,
)
if locked_existing is None:
@@ -635,12 +672,35 @@ class ReturnService:
await query_db.rollback()
return cls._success('申请成功', return_id)
inventories, _, _ = await cls._lock_request_pallets(
inventories, pallets, filling_by_id, fresh_site_ids = await cls._lock_request_pallets(
query_db,
ordered_ids,
page_object.source_site_id,
current_user,
)
fresh_warehouse_ids = None if fresh_site_ids is None else []
fresh_existing = await OperationDao.get_return_by_request_idempotency_key(
query_db,
page_object.idempotency_key,
site_ids=fresh_site_ids,
warehouse_ids=fresh_warehouse_ids,
)
if fresh_existing is not None:
if not cls._is_exact_request_result(fresh_existing, page_object, request_hash):
raise ServiceException(message='申请退回失败,幂等键已被其他请求使用')
fresh_existing_id = int(fresh_existing.return_id)
await cls._lock_and_reauthorize_request_site(query_db, fresh_existing, current_user)
await query_db.rollback()
return cls._success('申请成功', fresh_existing_id)
await cls._lock_new_request_route(query_db, page_object, current_user)
cls._validate_locked_request_pallets(
inventories,
pallets,
filling_by_id,
page_object.source_site_id,
current_user,
)
for inventory in inventories:
PalletStateMachine.ensure_transition(
PalletFlowState(inventory.flow_state),
@@ -278,6 +278,26 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase):
self.assertIn('biz_return_order.target_warehouse_id IN (7)', sql)
self.assertIn(' OR ', sql)
async def test_request_key_read_is_scoped_and_never_locks_missing_unique_key(self) -> None:
result = MagicMock()
result.scalar_one_or_none.return_value = None
self.db.execute.return_value = result
await OperationDao.get_return_by_request_idempotency_key(
self.db,
'request-key',
site_ids=[9],
warehouse_ids=[],
)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(BizReturnOrder.__table__))
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect)
self.assertIn("biz_return_order.request_idempotency_key = 'request-key'", sql)
self.assertIn('biz_return_order.source_site_id IN (9)', sql)
self.assertNotIn('FOR UPDATE', sql)
@unittest.skipUnless(ReturnService is not None, 'ReturnService 尚未实现')
class ReturnServiceContractRedTest(unittest.TestCase):
@@ -561,6 +581,51 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase):
await ReturnService.scan_return_pallet_services(self.db, 'qr-2', query, self.user)
self.assertEqual('无权访问该退回单', hidden_error.exception.message)
async def test_confirm_scan_hides_missing_and_foreign_pallet_equally_for_normal_user(self) -> None:
order = make_return_order(snapshots=((2, 102, 8),))
query = ReturnScanQueryModel(mode='confirm', returnId=13)
messages = []
for pallet in (None, make_pallet(5)):
with self.subTest(pallet_exists=pallet is not None):
inventory_read = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(OperationDao, 'get_return_by_id', new=AsyncMock(return_value=order)),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
patch.object(MasterDao, 'get_pallet_inventory', new=inventory_read),
):
try:
await ReturnService.scan_return_pallet_services(self.db, 'qr-5', query, self.user)
except Exception as exc:
raised = exc
else:
raised = None
self.assertIsInstance(raised, PermissionException)
messages.append(getattr(raised, 'message', '未抛出异常'))
inventory_read.assert_not_awaited()
self.assertEqual(['无权访问该托盘', '无权访问该托盘'], messages)
async def test_admin_confirm_scan_keeps_precise_missing_and_foreign_pallet_errors(self) -> None:
admin = make_user(1)
order = make_return_order(snapshots=((2, 102, 8),))
query = ReturnScanQueryModel(mode='confirm', returnId=13)
cases = (
(None, '托盘不存在'),
(make_pallet(5), '扫码托盘不属于该退回单'),
)
for pallet, expected_message in cases:
with self.subTest(expected_message=expected_message):
with (
patch.object(OperationDao, 'get_return_by_id', new=AsyncMock(return_value=order)),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock()) as inventory_read,
self.assertRaises(ServiceException) as raised,
):
await ReturnService.scan_return_pallet_services(self.db, 'qr-5', query, admin)
self.assertEqual(expected_message, raised.exception.message)
inventory_read.assert_not_awaited()
@unittest.skipUnless(ReturnService is not None, 'ReturnService 尚未实现')
class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
@@ -575,6 +640,337 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
idempotencyKey='request-key',
)
async def test_concurrent_same_request_returns_winner_after_inventory_wait(self) -> None: # noqa: PLR0915
request_hash = ReturnService._build_request_hash(self.page)
winner = make_return_order(request_hash=request_hash)
inventories = [
make_inventory(2, state=PalletFlowState.RETURNING.value),
make_inventory(5, remaining=0, state=PalletFlowState.RETURNING.value),
]
actions: list[str] = []
key_count = 0
scope_count = 0
access_count = 0
rollback_count = 0
async def read_key(*_args: Any, **_kwargs: Any) -> BizReturnOrder | None:
nonlocal key_count
key_count += 1
actions.append('key-preflight' if key_count == 1 else 'key-fresh')
return None if key_count == 1 else winner
async def read_scope(*_args: Any) -> list[int]:
nonlocal scope_count
scope_count += 1
actions.append('scope-preflight' if scope_count == 1 else 'scope-fresh')
return [9]
async def check_access(*_args: Any, **kwargs: Any) -> None:
nonlocal access_count
access_count += 1
if kwargs.get('for_update', False):
actions.append('mapping-lock')
else:
actions.append('access-preflight' if access_count == 1 else 'access-fresh')
async def rollback() -> None:
nonlocal rollback_count
rollback_count += 1
actions.append('rollback-preflight' if rollback_count == 1 else 'rollback-result')
async def lock_inventories(*_args: Any) -> list[BizPalletInventory]:
actions.append('inventory-lock')
return inventories
async def lock_pallets(*_args: Any) -> list[BizPallet]:
actions.append('pallet-lock')
return [make_pallet(2), make_pallet(5)]
async def lock_filling(*_args: Any) -> BizFillingRecord:
actions.append('filling-lock')
return make_filling(2)
async def lock_site(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
actions.append('site-lock')
return SimpleNamespace(status='0')
key_read = AsyncMock(side_effect=read_key)
scope_read = AsyncMock(side_effect=read_scope)
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(
OperationDao,
'get_active_filling_for_update',
new=AsyncMock(side_effect=lock_filling),
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=check_access)),
patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=lock_site)),
patch.object(OperationDao, 'add_return_order', new=AsyncMock()) as add_order,
patch.object(OperationDao, 'add_return_items', new=AsyncMock()) as add_items,
patch.object(InventoryDao, 'add_event', new=AsyncMock()) as add_event,
):
try:
result = await ReturnService.request_return_services(self.db, self.page, self.user)
except ServiceException as exc:
self.fail(f'锁等待后的精确重试被库存状态提前拒绝:{exc.message}')
self.assertEqual(13, result.result)
self.assertEqual(
[
'scope-preflight',
'access-preflight',
'key-preflight',
'rollback-preflight',
'inventory-lock',
'access-fresh',
'scope-fresh',
'pallet-lock',
'filling-lock',
'key-fresh',
'site-lock',
'mapping-lock',
'rollback-result',
],
actions,
)
self.assertEqual(2, key_read.await_count)
self.assertEqual(2, scope_read.await_count)
add_order.assert_not_awaited()
add_items.assert_not_awaited()
add_event.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.assertEqual([PalletFlowState.RETURNING.value] * 2, [item.flow_state for item in inventories])
async def test_concurrent_same_key_different_payload_is_stable_idempotency_conflict(self) -> None:
winner = make_return_order(request_hash=ReturnService._build_request_hash(self.page))
conflict_page = self.page.model_copy(update={'target_warehouse_id': 8})
inventories = [
make_inventory(2, state=PalletFlowState.RETURNING.value),
make_inventory(5, remaining=0, state=PalletFlowState.RETURNING.value),
]
key_read = AsyncMock(side_effect=[None, winner])
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=key_read),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=inventories)),
patch.object(
InventoryDao,
'get_pallets_by_ids',
new=AsyncMock(return_value=[make_pallet(2), make_pallet(5)]),
),
patch.object(
OperationDao,
'get_active_filling_for_update',
new=AsyncMock(return_value=make_filling(2)),
),
patch.object(OperationDao, 'add_return_order', new=AsyncMock()) as add_order,
self.assertRaises(ServiceException) as raised,
):
await ReturnService.request_return_services(self.db, conflict_page, self.user)
self.assertEqual('申请退回失败,幂等键已被其他请求使用', raised.exception.message)
self.assertEqual(2, key_read.await_count)
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 = [
make_inventory(2, state=PalletFlowState.RETURNING.value),
make_inventory(5, remaining=0, state=PalletFlowState.RETURNING.value),
]
scope_read = AsyncMock(side_effect=[[9], [9, 10]])
key_read = AsyncMock(side_effect=[None, winner])
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(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=inventories)),
patch.object(
InventoryDao,
'get_pallets_by_ids',
new=AsyncMock(return_value=[make_pallet(2), make_pallet(5)]),
),
patch.object(
OperationDao,
'get_active_filling_for_update',
new=AsyncMock(return_value=make_filling(2)),
),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
):
result = await ReturnService.request_return_services(self.db, self.page, self.user)
self.assertEqual(13, result.result)
self.assertEqual(
[
{'site_ids': [9], 'warehouse_ids': []},
{'site_ids': [9, 10], 'warehouse_ids': []},
],
[call.kwargs for call in key_read.await_args_list],
)
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_request_hides_site_revoked_during_inventory_wait(self) -> None:
inventories = [
make_inventory(2),
make_inventory(5, remaining=0, state=PalletFlowState.AT_SITE_EMPTY.value),
]
scope_read = AsyncMock(side_effect=[[9], []])
key_read = AsyncMock(return_value=None)
inventory_locks = AsyncMock(return_value=inventories)
pallet_locks = AsyncMock(return_value=[make_pallet(2), make_pallet(5)])
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(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_locks),
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_locks),
self.assertRaises(PermissionException) as raised,
):
await ReturnService.request_return_services(self.db, self.page, self.user)
self.assertEqual('无权访问该托盘', raised.exception.message)
inventory_locks.assert_awaited_once_with(self.db, [2, 5])
pallet_locks.assert_not_awaited()
self.assertEqual(2, scope_read.await_count)
self.assertEqual(1, key_read.await_count)
self.db.commit.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_preflight_existing_uses_only_copied_id_after_rollback(self) -> None:
request_hash = ReturnService._build_request_hash(self.page)
class ExpiringReturnProbe:
def __init__(self) -> None:
self.expired = False
self.return_id_reads = 0
self.request_idempotency_key = 'request-key'
self.request_hash = request_hash
self.source_site_id = 9
self.target_warehouse_id = 7
self.status = ReturnStatus.REQUESTED.value
@property
def return_id(self) -> int:
if self.expired:
raise AssertionError('rollback 后禁止访问预检 return ORM')
self.return_id_reads += 1
return 13
probe = ExpiringReturnProbe()
fresh = make_return_order(request_hash=request_hash)
async def rollback() -> None:
probe.expired = True
self.db.rollback.side_effect = rollback
order_lock = AsyncMock(return_value=fresh)
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=AsyncMock(return_value=probe),
),
patch.object(OperationDao, 'get_return_for_update', new=order_lock),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as inventory_locks,
):
result = await ReturnService.request_return_services(self.db, self.page, self.user)
self.assertEqual(13, result.result)
self.assertTrue(probe.expired)
self.assertEqual(1, probe.return_id_reads)
order_lock.assert_awaited_once_with(self.db, 13, site_ids=[9], warehouse_ids=[])
inventory_locks.assert_not_awaited()
self.assertEqual(2, self.db.rollback.await_count)
async def test_request_hides_missing_and_foreign_site_pallets_equally_for_normal_user(self) -> None:
cases = (
('missing', []),
('foreign-site', [make_inventory(2, site_id=10), make_inventory(5, site_id=10)]),
)
messages = []
for name, inventories in cases:
with self.subTest(case=name):
db = MagicMock(spec=AsyncSession)
pallet_locks = AsyncMock(return_value=[make_pallet(2), make_pallet(5)])
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(
OperationDao,
'get_return_by_request_idempotency_key',
new=AsyncMock(return_value=None),
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
InventoryDao,
'get_inventories_for_update',
new=AsyncMock(return_value=inventories),
),
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_locks),
):
try:
await ReturnService.request_return_services(db, self.page, self.user)
except Exception as exc:
raised = exc
else:
raised = None
self.assertIsInstance(raised, PermissionException)
messages.append(getattr(raised, 'message', '未抛出异常'))
pallet_locks.assert_not_awaited()
db.commit.assert_not_awaited()
db.rollback.assert_awaited()
self.assertEqual(['无权访问该托盘', '无权访问该托盘'], messages)
async def test_request_hides_invalid_active_filling_association_for_normal_user(self) -> None:
inventories = [
make_inventory(2),
make_inventory(5, remaining=0, state=PalletFlowState.AT_SITE_EMPTY.value),
]
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(
OperationDao,
'get_return_by_request_idempotency_key',
new=AsyncMock(return_value=None),
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=inventories)),
patch.object(
InventoryDao,
'get_pallets_by_ids',
new=AsyncMock(return_value=[make_pallet(2), make_pallet(5)]),
),
patch.object(
OperationDao,
'get_active_filling_for_update',
new=AsyncMock(return_value=make_filling(5)),
),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
):
try:
await ReturnService.request_return_services(self.db, self.page, self.user)
except Exception as exc:
raised = exc
else:
raised = None
self.assertIsInstance(raised, PermissionException)
self.assertEqual('无权访问该托盘', getattr(raised, 'message', None))
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited()
async def test_request_freezes_all_pallets_saves_snapshots_and_appends_events(self) -> None:
inventories = [
make_inventory(2),
@@ -603,12 +999,16 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
return event
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 9])),
patch.object(
AccessDao,
'list_user_site_ids',
new=AsyncMock(return_value=[9, 9]),
) as scope_read,
patch.object(
OperationDao,
'get_return_by_request_idempotency_key',
new=AsyncMock(return_value=None),
),
) as key_read,
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=inventories)) as locks,
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=pallets)),
patch.object(
@@ -657,6 +1057,12 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(result.is_success)
self.assertEqual(13, result.result)
self.assertEqual(2, scope_read.await_count)
self.assertEqual(2, key_read.await_count)
self.assertEqual(
[{'site_ids': [9], 'warehouse_ids': []}, {'site_ids': [9], 'warehouse_ids': []}],
[call.kwargs for call in key_read.await_args_list],
)
locks.assert_awaited_once_with(self.db, [2, 5])
site_access.assert_awaited_with(self.db, self.user, 9, for_update=True)
warehouse_access.assert_not_awaited()
@@ -676,7 +1082,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual([4, 4], [event.after_line_id for event in events])
self.db.commit.assert_awaited_once()
async def test_request_rejects_mixed_site_and_rolls_back_whole_order(self) -> None:
async def test_request_hides_mixed_site_and_rolls_back_whole_order(self) -> None:
inventories = [make_inventory(2), make_inventory(5, site_id=10)]
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
@@ -696,15 +1102,45 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
new=AsyncMock(side_effect=[make_filling(2), make_filling(5)]),
),
patch.object(OperationDao, 'add_return_order', new=AsyncMock()) as add_order,
self.assertRaises(ServiceException) as raised,
self.assertRaises(PermissionException) as raised,
):
await ReturnService.request_return_services(self.db, self.page, self.user)
self.assertIn('同一现场', raised.exception.message)
self.assertEqual('无权访问该托盘', raised.exception.message)
add_order.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited()
async def test_admin_request_keeps_precise_missing_and_foreign_site_errors(self) -> None:
admin = make_user(1)
cases = (
([], '存在不存在或库存缺失的托盘'),
([make_inventory(2, site_id=10), make_inventory(5, site_id=10)], '同一退回单托盘必须属于同一现场'),
)
for inventories, expected_message in cases:
with self.subTest(expected_message=expected_message):
db = MagicMock(spec=AsyncSession)
with (
patch.object(
OperationDao,
'get_return_by_request_idempotency_key',
new=AsyncMock(return_value=None),
),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(
InventoryDao,
'get_inventories_for_update',
new=AsyncMock(return_value=inventories),
),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock()) as pallet_locks,
self.assertRaises(ServiceException) as raised,
):
await ReturnService.request_return_services(db, self.page, admin)
self.assertEqual(expected_message, raised.exception.message)
pallet_locks.assert_not_awaited()
db.commit.assert_not_awaited()
db.rollback.assert_awaited()
async def test_request_same_key_same_hash_returns_original_and_different_hash_conflicts(self) -> None:
request_hash = ReturnService._build_request_hash(self.page)
exact = make_return_order(request_hash=request_hash)
@@ -743,12 +1179,13 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
conflict_db.rollback.assert_awaited()
async def test_request_checks_site_mapping_before_locking_pallets(self) -> None:
key_read = AsyncMock(return_value=None)
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(
OperationDao,
'get_return_by_request_idempotency_key',
new=AsyncMock(return_value=None),
new=key_read,
),
patch.object(
AccessService,
@@ -770,10 +1207,30 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
raised = None
self.assertIsInstance(raised, PermissionException)
self.assertEqual('无权访问该客户现场', raised.message)
self.assertEqual('无权访问该托盘', raised.message)
key_read.assert_not_awaited()
pallet_locks.assert_not_awaited()
self.db.rollback.assert_awaited()
async def test_request_rejects_unscoped_requested_site_before_inventory_locks(self) -> None:
key_read = AsyncMock(return_value=None)
access = AsyncMock()
inventory_locks = AsyncMock()
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[])),
patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=key_read),
patch.object(AccessService, 'assert_site_access', new=access),
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_locks),
self.assertRaises(PermissionException) as raised,
):
await ReturnService.request_return_services(self.db, self.page, self.user)
self.assertEqual('无权访问该托盘', raised.exception.message)
key_read.assert_not_awaited()
access.assert_not_awaited()
inventory_locks.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_admin_request_idempotency_scope_is_unrestricted(self) -> None:
admin = make_user(1)
request_hash = ReturnService._build_request_hash(self.page)