From c9180b38323d2f796d5189d3715056e69d6c71ef 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 00:49:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=80=80=E5=9B=9E=E9=94=81=E5=BA=8F=E5=B9=B6=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=90=8E=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../module_business/service/return_service.py | 27 ++--- .../module_business/test_return_service.py | 107 ++++++++++++++++++ 2 files changed, 121 insertions(+), 13 deletions(-) diff --git a/shierfeng-fastapi-backend/module_business/service/return_service.py b/shierfeng-fastapi-backend/module_business/service/return_service.py index 41ec3ef..ca62470 100644 --- a/shierfeng-fastapi-backend/module_business/service/return_service.py +++ b/shierfeng-fastapi-backend/module_business/service/return_service.py @@ -533,6 +533,16 @@ class ReturnService: page_object: ReturnRequestModel, current_user: CurrentUserModel, ) -> None: + warehouse = await MasterDao.get_warehouse( + query_db, + page_object.target_warehouse_id, + for_update=True, + ) + if warehouse is None: + raise ServiceException(message='目标仓库不存在') + if warehouse.status != '0': + raise ServiceException(message='目标仓库已停用') + site = await MasterDao.get_site(query_db, page_object.source_site_id, for_update=True) await AccessService.assert_site_access( query_db, @@ -545,16 +555,6 @@ class ReturnService: if site.status != '0': raise ServiceException(message='来源客户现场已停用') - warehouse = await MasterDao.get_warehouse( - query_db, - page_object.target_warehouse_id, - for_update=True, - ) - if warehouse is None: - raise ServiceException(message='目标仓库不存在') - if warehouse.status != '0': - raise ServiceException(message='目标仓库已停用') - @classmethod async def _lock_and_reauthorize_request_site( cls, @@ -733,9 +733,10 @@ class ReturnService: update_time=current_time, ) return_order = await OperationDao.add_return_order(query_db, return_order) + return_id = int(return_order.return_id) await OperationDao.add_return_items( query_db, - cls._build_return_items(return_order.return_id, inventories, user_name, current_time), + cls._build_return_items(return_id, inventories, user_name, current_time), ) for inventory in inventories: @@ -751,7 +752,7 @@ class ReturnService: pallet_id=inventory.pallet_id, event_type=PalletEventType.RETURN_REQUESTED.value, source_type='RETURN', - source_id=return_order.return_id, + source_id=return_id, before_state=before_state, after_state=PalletFlowState.RETURNING.value, before_warehouse_id=before_warehouse_id, @@ -770,7 +771,7 @@ class ReturnService: await InventoryDao.add_event(query_db, inventory, event) await query_db.commit() - return cls._success('申请成功', return_order.return_id) + return cls._success('申请成功', return_id) except IntegrityError: await query_db.rollback() return await cls._recover_request_integrity_error( diff --git a/shierfeng-fastapi-backend/tests/module_business/test_return_service.py b/shierfeng-fastapi-backend/tests/module_business/test_return_service.py index 22f6f7e..73f57b5 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_return_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_return_service.py @@ -640,6 +640,113 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): idempotencyKey='request-key', ) + async def test_new_request_route_locks_warehouse_before_site_and_mapping(self) -> None: + actions: list[str] = [] + + async def lock_warehouse(*_args: Any, **_kwargs: Any) -> SimpleNamespace: + actions.append('warehouse-row') + return SimpleNamespace(status='0') + + async def lock_site(*_args: Any, **_kwargs: Any) -> SimpleNamespace: + actions.append('site-row') + return SimpleNamespace(status='0') + + async def lock_site_mapping(*_args: Any, **_kwargs: Any) -> None: + actions.append('site-mapping') + + warehouse_access = AsyncMock() + with ( + patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=lock_warehouse)) as warehouse_read, + patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=lock_site)) as site_read, + patch.object( + AccessService, 'assert_site_access', new=AsyncMock(side_effect=lock_site_mapping) + ) as site_access, + patch.object(AccessService, 'assert_warehouse_access', new=warehouse_access), + ): + await ReturnService._lock_new_request_route(self.db, self.page, self.user) + + self.assertEqual(['warehouse-row', 'site-row', 'site-mapping'], actions) + warehouse_read.assert_awaited_once_with(self.db, 7, for_update=True) + site_read.assert_awaited_once_with(self.db, 9, for_update=True) + site_access.assert_awaited_once_with(self.db, self.user, 9, for_update=True) + warehouse_access.assert_not_awaited() + + async def test_request_caches_return_id_before_commit_expires_order(self) -> None: + class ExpiringReturnOrder: + def __init__(self) -> None: + self.expired = False + self.return_id_reads = 0 + + @property + def return_id(self) -> int: + self.return_id_reads += 1 + if self.expired: + raise RuntimeError('commit 后访问了已过期的 return_order.return_id') + return 13 + + inventories = [ + make_inventory(2), + make_inventory(5, remaining=0, state=PalletFlowState.AT_SITE_EMPTY.value), + ] + probe = ExpiringReturnOrder() + captured_items: list[BizReturnItem] = [] + captured_events: list[BizPalletEvent] = [] + + async def add_items(_db: AsyncSession, items: list[BizReturnItem]) -> list[BizReturnItem]: + captured_items.extend(items) + return items + + async def add_event( + _db: AsyncSession, + _inventory: BizPalletInventory, + event: BizPalletEvent, + ) -> BizPalletEvent: + captured_events.append(event) + return event + + async def expire_order() -> None: + probe.expired = True + + self.db.commit.side_effect = expire_order + warehouse_access = AsyncMock() + 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(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(AccessService, 'assert_site_access', new=AsyncMock()), + patch.object(AccessService, 'assert_warehouse_access', new=warehouse_access), + patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object(OperationDao, 'add_return_order', new=AsyncMock(return_value=probe)), + patch.object(OperationDao, 'add_return_items', new=AsyncMock(side_effect=add_items)), + patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)), + ): + try: + result = await ReturnService.request_return_services(self.db, self.page, self.user) + except RuntimeError as exc: + self.fail(str(exc)) + + self.assertEqual(13, result.result) + self.assertEqual(1, probe.return_id_reads) + self.assertEqual([13, 13], [item.return_id for item in captured_items]) + self.assertEqual([13, 13], [event.source_id for event in captured_events]) + warehouse_access.assert_not_awaited() + self.db.commit.assert_awaited_once() + 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)