From f5df2150d8f5fd79d304f75ed59cf7f83faea0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Wed, 23 Sep 2026 08:27:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E4=BA=94=E7=B1=BB=E6=B5=8B=E8=AF=95=E4=B8=9A=E5=8A=A1=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/filling_controller.py | 15 +- .../controller/inbound_controller.py | 22 +- .../controller/return_controller.py | 17 +- .../controller/shipment_controller.py | 25 +- .../controller/usage_controller.py | 16 +- .../module_business/dao/inventory_dao.py | 29 +- .../module_business/dao/operation_dao.py | 137 ++++-- .../service/filling_service.py | 30 +- .../service/inbound_service.py | 109 ++--- .../module_business/service/return_service.py | 94 ++++- .../service/shipment_service.py | 124 +++--- .../module_business/service/usage_service.py | 44 +- .../module_business/test_filling_service.py | 147 +++++-- .../module_business/test_inbound_service.py | 375 ++++++++--------- .../module_business/test_inventory_dao.py | 118 ++++-- .../module_business/test_return_service.py | 174 +++++--- .../module_business/test_shipment_service.py | 393 +++++++----------- .../module_business/test_usage_service.py | 168 +++++--- 18 files changed, 1206 insertions(+), 831 deletions(-) diff --git a/shierfeng-fastapi-backend/module_business/controller/filling_controller.py b/shierfeng-fastapi-backend/module_business/controller/filling_controller.py index 01c5faa..3a947bf 100644 --- a/shierfeng-fastapi-backend/module_business/controller/filling_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/filling_controller.py @@ -11,6 +11,8 @@ from common.enums import BusinessType from common.router import APIRouterPro from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dependency.test_batch_dependency import TestBatchScopeDependency +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.operation_vo import ( BusinessOptionModel, FillingCreateModel, @@ -40,11 +42,13 @@ async def get_business_filling_list( filling_page_query: Annotated[FillingPageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await FillingService.get_filling_list_services( query_db, filling_page_query, current_user, + test_scope, is_page=True, ) return ResponseUtil.success(model_content=result) @@ -61,8 +65,9 @@ async def get_business_filling_strain_options( request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await FillingService.get_strain_options_services(query_db, current_user) + result = await FillingService.get_strain_options_services(query_db, current_user, test_scope) return ResponseUtil.success(data=result) @@ -81,11 +86,13 @@ async def scan_business_filling_pallet( ], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await FillingService.scan_filling_pallet_services( query_db, qr_token, current_user, + test_scope, ) return ResponseUtil.success(data=result) @@ -102,8 +109,9 @@ async def query_detail_business_filling( filling_id: Annotated[int, Path(gt=0, description='装填记录ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await FillingService.filling_detail_services(query_db, filling_id, current_user) + result = await FillingService.filling_detail_services(query_db, filling_id, current_user, test_scope) return ResponseUtil.success(data=result) @@ -120,6 +128,7 @@ async def add_business_filling( add_filling: FillingCreateModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await FillingService.fill_pallet_services(query_db, add_filling, current_user) + result = await FillingService.fill_pallet_services(query_db, add_filling, current_user, test_scope) return ResponseUtil.success(msg=result.message) diff --git a/shierfeng-fastapi-backend/module_business/controller/inbound_controller.py b/shierfeng-fastapi-backend/module_business/controller/inbound_controller.py index 1c2e14d..ab4dee6 100644 --- a/shierfeng-fastapi-backend/module_business/controller/inbound_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/inbound_controller.py @@ -11,6 +11,8 @@ from common.enums import BusinessType from common.router import APIRouterPro from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dependency.test_batch_dependency import TestBatchScopeDependency +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.operation_vo import ( BusinessOptionModel, ConfirmCommandModel, @@ -43,11 +45,13 @@ async def get_business_inbound_list( inbound_page_query: Annotated[InboundPageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await InboundService.get_inbound_list_services( query_db, inbound_page_query, current_user, + test_scope, is_page=True, ) return ResponseUtil.success(model_content=result) @@ -64,8 +68,9 @@ async def get_business_inbound_warehouse_options( request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await InboundService.get_warehouse_options_services(query_db, current_user) + result = await InboundService.get_warehouse_options_services(query_db, current_user, test_scope) return ResponseUtil.success(data=result) @@ -85,12 +90,14 @@ async def scan_business_inbound_pallet( inbound_scan_query: Annotated[InboundScanQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await InboundService.scan_inbound_pallet_services( query_db, qr_token, inbound_scan_query, current_user, + test_scope, ) return ResponseUtil.success(data=result) @@ -107,8 +114,9 @@ async def query_detail_business_inbound( inbound_id: Annotated[int, Path(gt=0, description='入库单ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await InboundService.inbound_detail_services(query_db, inbound_id, current_user) + result = await InboundService.inbound_detail_services(query_db, inbound_id, current_user, test_scope) return ResponseUtil.success(data=result) @@ -125,8 +133,9 @@ async def add_business_inbound( add_inbound: InboundCreateModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await InboundService.add_inbound_services(query_db, add_inbound, current_user) + result = await InboundService.add_inbound_services(query_db, add_inbound, current_user, test_scope) return ResponseUtil.success(msg=result.message) @@ -143,8 +152,9 @@ async def edit_business_inbound( edit_inbound: InboundUpdateModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await InboundService.edit_inbound_services(query_db, edit_inbound, current_user) + result = await InboundService.edit_inbound_services(query_db, edit_inbound, current_user, test_scope) return ResponseUtil.success(msg=result.message) @@ -164,11 +174,13 @@ async def delete_business_inbound( ], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: result = await InboundService.delete_inbound_services( query_db, [int(inbound_id) for inbound_id in inbound_ids.split(',')], current_user, + test_scope, ) return ResponseUtil.success(msg=result.message) @@ -187,11 +199,13 @@ async def confirm_business_inbound( confirm_command: ConfirmCommandModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: result = await InboundService.confirm_inbound_services( query_db, inbound_id, confirm_command, current_user, + test_scope, ) return ResponseUtil.success(msg=result.message) diff --git a/shierfeng-fastapi-backend/module_business/controller/return_controller.py b/shierfeng-fastapi-backend/module_business/controller/return_controller.py index df334b6..5373c5a 100644 --- a/shierfeng-fastapi-backend/module_business/controller/return_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/return_controller.py @@ -13,6 +13,8 @@ from common.enums import BusinessType from common.router import APIRouterPro from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dependency.test_batch_dependency import TestBatchScopeDependency +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.operation_vo import ( BusinessOptionModel, PalletScanResponseModel, @@ -44,11 +46,13 @@ async def get_business_return_list( return_page_query: Annotated[ReturnPageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await ReturnService.get_return_list_services( query_db, return_page_query, current_user, + test_scope, is_page=True, ) return ResponseUtil.success(model_content=result) @@ -65,8 +69,9 @@ async def get_business_return_warehouse_options( request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ReturnService.get_warehouse_options_services(query_db, current_user) + result = await ReturnService.get_warehouse_options_services(query_db, current_user, test_scope) return ResponseUtil.success(data=result) @@ -86,12 +91,14 @@ async def scan_business_return_pallet( return_scan_query: Annotated[ReturnScanQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await ReturnService.scan_return_pallet_services( query_db, qr_token, return_scan_query, current_user, + test_scope, ) return ResponseUtil.success(data=result) @@ -109,8 +116,9 @@ async def request_business_return( request_command: ReturnRequestModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await ReturnService.request_return_services(query_db, request_command, current_user) + result = await ReturnService.request_return_services(query_db, request_command, current_user, test_scope) return ResponseUtil.success(msg=result.message) @@ -126,8 +134,9 @@ async def query_detail_business_return( return_id: Annotated[int, Path(gt=0, description='退回单ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ReturnService.return_detail_services(query_db, return_id, current_user) + result = await ReturnService.return_detail_services(query_db, return_id, current_user, test_scope) return ResponseUtil.success(data=result) @@ -145,11 +154,13 @@ async def confirm_business_return( confirm_command: ReturnConfirmModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: result = await ReturnService.confirm_return_services( query_db, return_id, confirm_command, current_user, + test_scope, ) return ResponseUtil.success(msg=result.message) diff --git a/shierfeng-fastapi-backend/module_business/controller/shipment_controller.py b/shierfeng-fastapi-backend/module_business/controller/shipment_controller.py index 5476d11..147ba3e 100644 --- a/shierfeng-fastapi-backend/module_business/controller/shipment_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/shipment_controller.py @@ -11,6 +11,8 @@ from common.enums import BusinessType from common.router import APIRouterPro from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dependency.test_batch_dependency import TestBatchScopeDependency +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.operation_vo import ( BusinessOptionModel, ConfirmCommandModel, @@ -43,11 +45,13 @@ async def get_business_shipment_list( shipment_page_query: Annotated[ShipmentPageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await ShipmentService.get_shipment_list_services( query_db, shipment_page_query, current_user, + test_scope, is_page=True, ) return ResponseUtil.success(model_content=result) @@ -64,8 +68,9 @@ async def get_business_shipment_warehouse_options( request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ShipmentService.get_warehouse_options_services(query_db, current_user) + result = await ShipmentService.get_warehouse_options_services(query_db, current_user, test_scope) return ResponseUtil.success(data=result) @@ -80,8 +85,9 @@ async def get_business_shipment_site_options( request: Request, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ShipmentService.get_site_options_services(query_db, current_user) + result = await ShipmentService.get_site_options_services(query_db, current_user, test_scope) return ResponseUtil.success(data=result) @@ -101,12 +107,14 @@ async def scan_business_shipment_pallet( shipment_scan_query: Annotated[ShipmentScanQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await ShipmentService.scan_shipment_pallet_services( query_db, qr_token, shipment_scan_query, current_user, + test_scope, ) return ResponseUtil.success(data=result) @@ -123,8 +131,9 @@ async def query_detail_business_shipment( shipment_id: Annotated[int, Path(gt=0, description='发货单ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ShipmentService.shipment_detail_services(query_db, shipment_id, current_user) + result = await ShipmentService.shipment_detail_services(query_db, shipment_id, current_user, test_scope) return ResponseUtil.success(data=result) @@ -141,8 +150,9 @@ async def add_business_shipment( add_shipment: ShipmentCreateModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await ShipmentService.add_shipment_services(query_db, add_shipment, current_user) + result = await ShipmentService.add_shipment_services(query_db, add_shipment, current_user, test_scope) return ResponseUtil.success(msg=result.message) @@ -159,8 +169,9 @@ async def edit_business_shipment( edit_shipment: ShipmentUpdateModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await ShipmentService.edit_shipment_services(query_db, edit_shipment, current_user) + result = await ShipmentService.edit_shipment_services(query_db, edit_shipment, current_user, test_scope) return ResponseUtil.success(msg=result.message) @@ -180,11 +191,13 @@ async def delete_business_shipment( ], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: result = await ShipmentService.delete_shipment_services( query_db, [int(shipment_id) for shipment_id in shipment_ids.split(',')], current_user, + test_scope, ) return ResponseUtil.success(msg=result.message) @@ -203,11 +216,13 @@ async def confirm_business_shipment( confirm_command: ConfirmCommandModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: result = await ShipmentService.confirm_shipment_services( query_db, shipment_id, confirm_command, current_user, + test_scope, ) return ResponseUtil.success(msg=result.message) diff --git a/shierfeng-fastapi-backend/module_business/controller/usage_controller.py b/shierfeng-fastapi-backend/module_business/controller/usage_controller.py index 3b1608c..f07c665 100644 --- a/shierfeng-fastapi-backend/module_business/controller/usage_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/usage_controller.py @@ -13,6 +13,8 @@ from common.enums import BusinessType from common.router import APIRouterPro from common.vo import DataResponseModel, PageResponseModel from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dependency.test_batch_dependency import TestBatchScopeDependency +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.operation_vo import ( BusinessOptionModel, PalletScanResponseModel, @@ -43,11 +45,13 @@ async def get_business_usage_list( usage_page_query: Annotated[UsagePageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await UsageService.get_usage_list_services( query_db, usage_page_query, current_user, + test_scope, is_page=True, ) return ResponseUtil.success(model_content=result) @@ -65,8 +69,9 @@ async def get_business_usage_line_options( site_id: Annotated[int, Query(alias='siteId', gt=0, description='客户现场ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await UsageService.get_line_options_services(query_db, site_id, current_user) + result = await UsageService.get_line_options_services(query_db, site_id, current_user, test_scope) return ResponseUtil.success(data=result) @@ -85,8 +90,9 @@ async def scan_business_usage_pallet( ], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await UsageService.scan_usage_pallet_services(query_db, qr_token, current_user) + result = await UsageService.scan_usage_pallet_services(query_db, qr_token, current_user, test_scope) return ResponseUtil.success(data=result) @@ -102,8 +108,9 @@ async def query_detail_business_usage( usage_id: Annotated[int, Path(gt=0, description='使用记录ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await UsageService.usage_detail_services(query_db, usage_id, current_user) + result = await UsageService.usage_detail_services(query_db, usage_id, current_user, test_scope) return ResponseUtil.success(data=result) @@ -120,6 +127,7 @@ async def add_business_usage( add_usage: UsageCreateModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> 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, test_scope) return ResponseUtil.success(msg=result.message, data=result.result) diff --git a/shierfeng-fastapi-backend/module_business/dao/inventory_dao.py b/shierfeng-fastapi-backend/module_business/dao/inventory_dao.py index c960ede..ede5462 100644 --- a/shierfeng-fastapi-backend/module_business/dao/inventory_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/inventory_dao.py @@ -1,6 +1,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory @@ -13,10 +14,14 @@ class InventoryDao: cls, db: AsyncSession, pallet_id: int, + scope: TestBatchScope, ) -> BizPalletInventory | None: result = await db.execute( select(BizPalletInventory) - .where(BizPalletInventory.pallet_id == pallet_id) + .where( + BizPalletInventory.pallet_id == pallet_id, + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) .with_for_update() .execution_options(populate_existing=True) ) @@ -27,6 +32,7 @@ class InventoryDao: cls, db: AsyncSession, pallet_ids: list[int], + scope: TestBatchScope, ) -> list[BizPalletInventory]: ordered_ids = sorted(set(pallet_ids)) if not ordered_ids: @@ -34,7 +40,10 @@ class InventoryDao: result = await db.execute( select(BizPalletInventory) - .where(BizPalletInventory.pallet_id.in_(ordered_ids)) + .where( + BizPalletInventory.pallet_id.in_(ordered_ids), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) .order_by(BizPalletInventory.pallet_id) .with_for_update() .execution_options(populate_existing=True) @@ -46,8 +55,14 @@ class InventoryDao: cls, db: AsyncSession, qr_token: str, + scope: TestBatchScope, ) -> BizPallet | None: - result = await db.execute(select(BizPallet).where(BizPallet.qr_token == qr_token)) + result = await db.execute( + select(BizPallet).where( + BizPallet.qr_token == qr_token, + batch_scope_condition(BizPallet.test_batch_id, scope), + ) + ) return result.scalar_one_or_none() @classmethod @@ -55,6 +70,7 @@ class InventoryDao: cls, db: AsyncSession, pallet_ids: list[int], + scope: TestBatchScope, ) -> list[BizPallet]: """在调用方已锁库存后,按托盘 ID 升序锁定资产行。""" ordered_ids = sorted(set(pallet_ids)) @@ -63,7 +79,10 @@ class InventoryDao: result = await db.execute( select(BizPallet) - .where(BizPallet.pallet_id.in_(ordered_ids)) + .where( + BizPallet.pallet_id.in_(ordered_ids), + batch_scope_condition(BizPallet.test_batch_id, scope), + ) .order_by(BizPallet.pallet_id) .with_for_update() .execution_options(populate_existing=True) @@ -76,11 +95,13 @@ class InventoryDao: db: AsyncSession, pallet_id: int, idempotency_key: str, + scope: TestBatchScope, ) -> BizPalletEvent | None: result = await db.execute( select(BizPalletEvent).where( BizPalletEvent.pallet_id == pallet_id, BizPalletEvent.idempotency_key == idempotency_key, + batch_scope_condition(BizPalletEvent.test_batch_id, scope), ) ) return result.scalar_one_or_none() diff --git a/shierfeng-fastapi-backend/module_business/dao/operation_dao.py b/shierfeng-fastapi-backend/module_business/dao/operation_dao.py index f09515e..6770002 100644 --- a/shierfeng-fastapi-backend/module_business/dao/operation_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/operation_dao.py @@ -7,6 +7,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute from common.vo import PageModel from module_business.domain.enums import DocumentStatus, PalletFlowState +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizFillingRecord, @@ -56,36 +57,49 @@ class OperationDao: @staticmethod def _inbound_scope_conditions( warehouse_ids: list[int] | None, + scope: TestBatchScope, ) -> list[ColumnElement[bool]]: if warehouse_ids is None: - return [] + return [batch_scope_condition(BizInboundOrder.test_batch_id, scope)] scoped_warehouse_ids = sorted(set(warehouse_ids)) - return [BizInboundOrder.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()] + return [ + batch_scope_condition(BizInboundOrder.test_batch_id, scope), + BizInboundOrder.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false(), + ] @staticmethod def _filling_scope_conditions( warehouse_ids: list[int] | None, + scope: TestBatchScope, ) -> list[ColumnElement[bool]]: if warehouse_ids is None: - return [] + return [batch_scope_condition(BizFillingRecord.test_batch_id, scope)] scoped_warehouse_ids = sorted(set(warehouse_ids)) - return [BizFillingRecord.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()] + return [ + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + BizFillingRecord.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false(), + ] @staticmethod def _usage_scope_conditions( site_ids: list[int] | None, + scope: TestBatchScope, ) -> list[ColumnElement[bool]]: if site_ids is None: - return [] + return [batch_scope_condition(BizUsageRecord.test_batch_id, scope)] scoped_site_ids = sorted(set(site_ids)) - return [BizUsageRecord.site_id.in_(scoped_site_ids) if scoped_site_ids else false()] + return [ + batch_scope_condition(BizUsageRecord.test_batch_id, scope), + BizUsageRecord.site_id.in_(scoped_site_ids) if scoped_site_ids else false(), + ] @staticmethod def _shipment_scope_conditions( warehouse_ids: list[int] | None, site_ids: list[int] | None, + scope: TestBatchScope, ) -> list[ColumnElement[bool]]: - conditions = [] + conditions = [batch_scope_condition(BizShipmentOrder.test_batch_id, scope)] if warehouse_ids is not None: scoped_warehouse_ids = sorted(set(warehouse_ids)) conditions.append( @@ -100,9 +114,10 @@ class OperationDao: def _return_scope_conditions( site_ids: list[int] | None, warehouse_ids: list[int] | None, + scope: TestBatchScope, ) -> list[ColumnElement[bool]]: if site_ids is None and warehouse_ids is None: - return [] + return [batch_scope_condition(BizReturnOrder.test_batch_id, scope)] visibility_conditions: list[ColumnElement[bool]] = [] if site_ids is not None: @@ -115,7 +130,7 @@ class OperationDao: visibility_conditions.append( BizReturnOrder.target_warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false() ) - return [or_(*visibility_conditions)] + return [batch_scope_condition(BizReturnOrder.test_batch_id, scope), or_(*visibility_conditions)] @staticmethod async def _paginate( @@ -180,6 +195,7 @@ class OperationDao: item_model: type[Any], item_order_id_column: InstrumentedAttribute, item_id_column: InstrumentedAttribute, + scope: TestBatchScope, *order_conditions: ColumnElement[bool], ) -> Any | None: order = await cls._get_by_id( @@ -193,7 +209,9 @@ class OperationDao: return None item_result = await db.execute( - select(item_model).where(item_order_id_column == order_id).order_by(item_id_column) + select(item_model) + .where(item_order_id_column == order_id, batch_scope_condition(item_model.test_batch_id, scope)) + .order_by(item_id_column) ) order.items = list(item_result.scalars().all()) return order @@ -260,6 +278,7 @@ class OperationDao: cls, db: AsyncSession, inbound_id: int, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, ) -> BizInboundOrder | None: inbound = await cls._get_for_update( @@ -267,7 +286,7 @@ class OperationDao: BizInboundOrder, BizInboundOrder.inbound_id, inbound_id, - *cls._inbound_scope_conditions(warehouse_ids), + *cls._inbound_scope_conditions(warehouse_ids, scope), ) if inbound is None: return None @@ -275,7 +294,10 @@ class OperationDao: # 父单锁定后用 locking read 获取最新明细,避免 MySQL RR 继续读取确认前的旧快照。 item_result = await db.execute( select(BizInboundItem) - .where(BizInboundItem.inbound_id == inbound_id) + .where( + BizInboundItem.inbound_id == inbound_id, + batch_scope_condition(BizInboundItem.test_batch_id, scope), + ) .order_by(BizInboundItem.pallet_id, BizInboundItem.inbound_item_id) .with_for_update() .execution_options(populate_existing=True) @@ -291,6 +313,7 @@ class OperationDao: cls, db: AsyncSession, shipment_id: int, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, site_ids: list[int] | None = None, ) -> BizShipmentOrder | None: @@ -299,7 +322,7 @@ class OperationDao: BizShipmentOrder, BizShipmentOrder.shipment_id, shipment_id, - *cls._shipment_scope_conditions(warehouse_ids, site_ids), + *cls._shipment_scope_conditions(warehouse_ids, site_ids, scope), ) if shipment is None: return None @@ -307,7 +330,10 @@ class OperationDao: # 父单锁定后读取当前明细,避免 MySQL RR 沿用确认前的快照。 item_result = await db.execute( select(BizShipmentItem) - .where(BizShipmentItem.shipment_id == shipment_id) + .where( + BizShipmentItem.shipment_id == shipment_id, + batch_scope_condition(BizShipmentItem.test_batch_id, scope), + ) .order_by(BizShipmentItem.pallet_id, BizShipmentItem.shipment_item_id) .with_for_update() .execution_options(populate_existing=True) @@ -323,6 +349,7 @@ class OperationDao: cls, db: AsyncSession, return_id: int, + scope: TestBatchScope, site_ids: list[int] | None = None, warehouse_ids: list[int] | None = None, ) -> BizReturnOrder | None: @@ -331,7 +358,7 @@ class OperationDao: BizReturnOrder, BizReturnOrder.return_id, return_id, - *cls._return_scope_conditions(site_ids, warehouse_ids), + *cls._return_scope_conditions(site_ids, warehouse_ids, scope), ) if return_order is None: return None @@ -339,7 +366,10 @@ class OperationDao: # 父单锁定后使用 locking read 获取当前明细,避免 MySQL RR 沿用旧快照。 item_result = await db.execute( select(BizReturnItem) - .where(BizReturnItem.return_id == return_id) + .where( + BizReturnItem.return_id == return_id, + batch_scope_condition(BizReturnItem.test_batch_id, scope), + ) .order_by(BizReturnItem.pallet_id, BizReturnItem.return_item_id) .with_for_update() .execution_options(populate_existing=True) @@ -355,6 +385,7 @@ class OperationDao: cls, db: AsyncSession, filling_id: int, + scope: TestBatchScope, ) -> BizFillingRecord | None: """按已锁库存的 active_filling_id 锁定仍处于活动状态的装填周期。""" return await cls._get_for_update( @@ -362,6 +393,7 @@ class OperationDao: BizFillingRecord, BizFillingRecord.filling_id, filling_id, + batch_scope_condition(BizFillingRecord.test_batch_id, scope), BizFillingRecord.active_slot == 'ACTIVE', ) @@ -370,6 +402,7 @@ class OperationDao: cls, db: AsyncSession, idempotency_key: str, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, ) -> BizInboundOrder | None: return await cls._get_by_key( @@ -377,7 +410,7 @@ class OperationDao: BizInboundOrder, BizInboundOrder.confirm_idempotency_key, idempotency_key, - *cls._inbound_scope_conditions(warehouse_ids), + *cls._inbound_scope_conditions(warehouse_ids, scope), ) @classmethod @@ -385,6 +418,7 @@ class OperationDao: cls, db: AsyncSession, idempotency_key: str, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, site_ids: list[int] | None = None, ) -> BizShipmentOrder | None: @@ -393,7 +427,7 @@ class OperationDao: BizShipmentOrder, BizShipmentOrder.confirm_idempotency_key, idempotency_key, - *cls._shipment_scope_conditions(warehouse_ids, site_ids), + *cls._shipment_scope_conditions(warehouse_ids, site_ids, scope), ) @classmethod @@ -401,6 +435,7 @@ class OperationDao: cls, db: AsyncSession, idempotency_key: str, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, ) -> BizFillingRecord | None: return await cls._get_by_key( @@ -408,7 +443,7 @@ class OperationDao: BizFillingRecord, BizFillingRecord.idempotency_key, idempotency_key, - *cls._filling_scope_conditions(warehouse_ids), + *cls._filling_scope_conditions(warehouse_ids, scope), ) @classmethod @@ -416,6 +451,7 @@ class OperationDao: cls, db: AsyncSession, idempotency_key: str, + scope: TestBatchScope, site_ids: list[int] | None = None, ) -> BizUsageRecord | None: return await cls._get_by_key( @@ -423,7 +459,7 @@ class OperationDao: BizUsageRecord, BizUsageRecord.idempotency_key, idempotency_key, - *cls._usage_scope_conditions(site_ids), + *cls._usage_scope_conditions(site_ids, scope), ) @classmethod @@ -433,6 +469,7 @@ class OperationDao: idempotency_key: str, source_site_id: int, target_warehouse_id: int, + scope: TestBatchScope, ) -> BizReturnOrder | None: return await cls._get_by_key( db, @@ -441,6 +478,7 @@ class OperationDao: idempotency_key, BizReturnOrder.source_site_id == source_site_id, BizReturnOrder.target_warehouse_id == target_warehouse_id, + batch_scope_condition(BizReturnOrder.test_batch_id, scope), ) @classmethod @@ -450,6 +488,7 @@ class OperationDao: idempotency_key: str, source_site_id: int, target_warehouse_id: int, + scope: TestBatchScope, ) -> BizReturnOrder | None: return await cls._get_by_key( db, @@ -458,6 +497,7 @@ class OperationDao: idempotency_key, BizReturnOrder.source_site_id == source_site_id, BizReturnOrder.target_warehouse_id == target_warehouse_id, + batch_scope_condition(BizReturnOrder.test_batch_id, scope), ) @classmethod @@ -465,10 +505,11 @@ class OperationDao: cls, db: AsyncSession, query_object: InboundPageQueryModel, + warehouse_ids: list[int] | None, + scope: TestBatchScope, is_page: bool = True, - warehouse_ids: list[int] | None = None, ) -> PageModel | list[dict[str, Any]]: - conditions = cls._inbound_scope_conditions(warehouse_ids) + conditions = cls._inbound_scope_conditions(warehouse_ids, scope) if query_object.inbound_no: conditions.append(BizInboundOrder.inbound_no.like(f'%{query_object.inbound_no}%')) if query_object.warehouse_id: @@ -490,6 +531,7 @@ class OperationDao: cls, db: AsyncSession, inbound_id: int, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, ) -> BizInboundOrder | None: return await cls._get_order_detail( @@ -500,7 +542,8 @@ class OperationDao: BizInboundItem, BizInboundItem.inbound_id, BizInboundItem.inbound_item_id, - *cls._inbound_scope_conditions(warehouse_ids), + scope, + *cls._inbound_scope_conditions(warehouse_ids, scope), ) @classmethod @@ -549,10 +592,11 @@ class OperationDao: cls, db: AsyncSession, query_object: FillingPageQueryModel, + warehouse_ids: list[int] | None, + scope: TestBatchScope, is_page: bool = True, - warehouse_ids: list[int] | None = None, ) -> PageModel | list[dict[str, Any]]: - conditions = cls._filling_scope_conditions(warehouse_ids) + conditions = cls._filling_scope_conditions(warehouse_ids, scope) if query_object.filling_no: conditions.append(BizFillingRecord.filling_no.like(f'%{query_object.filling_no}%')) if query_object.warehouse_id: @@ -584,6 +628,7 @@ class OperationDao: cls, db: AsyncSession, filling_id: int, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, ) -> BizFillingRecord | None: return await cls._get_by_id( @@ -591,7 +636,7 @@ class OperationDao: BizFillingRecord, BizFillingRecord.filling_id, filling_id, - *cls._filling_scope_conditions(warehouse_ids), + *cls._filling_scope_conditions(warehouse_ids, scope), ) @classmethod @@ -620,11 +665,12 @@ class OperationDao: cls, db: AsyncSession, query_object: ShipmentPageQueryModel, + warehouse_ids: list[int] | None, + site_ids: list[int] | None, + scope: TestBatchScope, is_page: bool = True, - warehouse_ids: list[int] | None = None, - site_ids: list[int] | None = None, ) -> PageModel | list[dict[str, Any]]: - conditions = cls._shipment_scope_conditions(warehouse_ids, site_ids) + conditions = cls._shipment_scope_conditions(warehouse_ids, site_ids, scope) if query_object.shipment_no: conditions.append(BizShipmentOrder.shipment_no.like(f'%{query_object.shipment_no}%')) if query_object.source_warehouse_id: @@ -648,6 +694,7 @@ class OperationDao: cls, db: AsyncSession, shipment_id: int, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, site_ids: list[int] | None = None, ) -> BizShipmentOrder | None: @@ -656,7 +703,7 @@ class OperationDao: BizShipmentOrder, BizShipmentOrder.shipment_id, shipment_id, - *cls._shipment_scope_conditions(warehouse_ids, site_ids), + *cls._shipment_scope_conditions(warehouse_ids, site_ids, scope), ) if shipment is None: return None @@ -674,9 +721,13 @@ class OperationDao: BizPalletInventory.current_line_id.is_(None), BizPalletInventory.active_filling_id.is_not(None), BizPalletInventory.remaining_packages > 0, + batch_scope_condition(BizPalletInventory.test_batch_id, scope), ), ) - .where(BizShipmentItem.shipment_id == shipment_id) + .where( + BizShipmentItem.shipment_id == shipment_id, + batch_scope_condition(BizShipmentItem.test_batch_id, scope), + ) .order_by(BizShipmentItem.shipment_item_id) ) items = [] @@ -686,7 +737,10 @@ class OperationDao: else: item_result = await db.execute( select(BizShipmentItem) - .where(BizShipmentItem.shipment_id == shipment_id) + .where( + BizShipmentItem.shipment_id == shipment_id, + batch_scope_condition(BizShipmentItem.test_batch_id, scope), + ) .order_by(BizShipmentItem.shipment_item_id) ) items = list(item_result.scalars().all()) @@ -746,10 +800,11 @@ class OperationDao: cls, db: AsyncSession, query_object: UsagePageQueryModel, + site_ids: list[int] | None, + scope: TestBatchScope, is_page: bool = True, - site_ids: list[int] | None = None, ) -> PageModel | list[dict[str, Any]]: - conditions = cls._usage_scope_conditions(site_ids) + conditions = cls._usage_scope_conditions(site_ids, scope) if query_object.usage_no: conditions.append(BizUsageRecord.usage_no.like(f'%{query_object.usage_no}%')) if query_object.site_id: @@ -779,6 +834,7 @@ class OperationDao: cls, db: AsyncSession, usage_id: int, + scope: TestBatchScope, site_ids: list[int] | None = None, ) -> BizUsageRecord | None: return await cls._get_by_id( @@ -786,7 +842,7 @@ class OperationDao: BizUsageRecord, BizUsageRecord.usage_id, usage_id, - *cls._usage_scope_conditions(site_ids), + *cls._usage_scope_conditions(site_ids, scope), ) @classmethod @@ -802,11 +858,12 @@ class OperationDao: cls, db: AsyncSession, query_object: ReturnPageQueryModel, + site_ids: list[int] | None, + warehouse_ids: list[int] | None, + scope: TestBatchScope, is_page: bool = True, - site_ids: list[int] | None = None, - warehouse_ids: list[int] | None = None, ) -> PageModel | list[dict[str, Any]]: - conditions = cls._return_scope_conditions(site_ids, warehouse_ids) + conditions = cls._return_scope_conditions(site_ids, warehouse_ids, scope) if query_object.return_no: conditions.append(BizReturnOrder.return_no.like(f'%{query_object.return_no}%')) if query_object.source_site_id: @@ -830,6 +887,7 @@ class OperationDao: cls, db: AsyncSession, return_id: int, + scope: TestBatchScope, site_ids: list[int] | None = None, warehouse_ids: list[int] | None = None, ) -> BizReturnOrder | None: @@ -841,7 +899,8 @@ class OperationDao: BizReturnItem, BizReturnItem.return_id, BizReturnItem.return_item_id, - *cls._return_scope_conditions(site_ids, warehouse_ids), + scope, + *cls._return_scope_conditions(site_ids, warehouse_ids, scope), ) @classmethod diff --git a/shierfeng-fastapi-backend/module_business/service/filling_service.py b/shierfeng-fastapi-backend/module_business/service/filling_service.py index 93d4b61..0a991f0 100644 --- a/shierfeng-fastapi-backend/module_business/service/filling_service.py +++ b/shierfeng-fastapi-backend/module_business/service/filling_service.py @@ -16,6 +16,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet, BizStrain from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory from module_business.entity.vo.master_vo import StrainPageQueryModel @@ -27,6 +28,7 @@ from module_business.entity.vo.operation_vo import ( PalletScanResponseModel, ) from module_business.service.access_service import AccessService +from module_business.service.test_batch_access_service import TestBatchAccessService class FillingService: @@ -180,6 +182,7 @@ class FillingService: query_db: AsyncSession, query_object: FillingPageQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[FillingModel]: warehouse_ids = await cls._warehouse_scope(query_db, current_user) @@ -192,8 +195,9 @@ class FillingService: result = await OperationDao.get_filling_list( query_db, query_object, - is_page, warehouse_ids=warehouse_ids, + scope=test_scope, + is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result models = [FillingModel.model_validate(record) for record in rows] @@ -207,11 +211,13 @@ class FillingService: query_db: AsyncSession, filling_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> FillingModel: warehouse_ids = await cls._warehouse_scope(query_db, current_user) filling = await OperationDao.get_filling_by_id( query_db, filling_id, + test_scope, warehouse_ids=warehouse_ids, ) if filling is None: @@ -223,11 +229,13 @@ class FillingService: cls, query_db: AsyncSession, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> list[BusinessOptionModel]: cls._current_identity(current_user) strains = await MasterDao.list_strain( query_db, StrainPageQueryModel(status='0'), + test_scope, False, ) options = [ @@ -247,11 +255,12 @@ class FillingService: query_db: AsyncSession, qr_token: str, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PalletScanResponseModel: - pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token) + pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) - inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id) + inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') if inventory.warehouse_id is None: @@ -292,6 +301,7 @@ class FillingService: page_object: FillingCreateModel, request_hash: str, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: warehouse_ids = await cls._warehouse_scope(query_db, current_user) @@ -300,6 +310,7 @@ class FillingService: recovered = await OperationDao.get_filling_by_idempotency_key( query_db, page_object.idempotency_key, + test_scope, warehouse_ids=[page_object.warehouse_id], ) if recovered is None: @@ -321,9 +332,11 @@ class FillingService: query_db: AsyncSession, page_object: FillingCreateModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: recovery_transaction_ended = False try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) request_hash = cls._build_request_hash(page_object) user_id, user_name = cls._current_identity(current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user) @@ -333,6 +346,7 @@ class FillingService: existing = await OperationDao.get_filling_by_idempotency_key( query_db, page_object.idempotency_key, + test_scope, warehouse_ids=[page_object.warehouse_id], ) if existing is not None: @@ -356,6 +370,7 @@ class FillingService: scanned_pallet = await InventoryDao.get_pallet_by_qr_token( query_db, page_object.qr_token, + test_scope, ) if scanned_pallet is None: cls._raise_pallet_not_accessible(current_user) @@ -363,6 +378,7 @@ class FillingService: inventory = await InventoryDao.get_inventory_for_update( query_db, scanned_pallet.pallet_id, + test_scope, ) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') @@ -376,6 +392,7 @@ class FillingService: locked_pallets = await InventoryDao.get_pallets_by_ids( query_db, [scanned_pallet.pallet_id], + test_scope, ) if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != scanned_pallet.pallet_id: cls._raise_pallet_not_accessible(current_user) @@ -385,6 +402,7 @@ class FillingService: concurrent = await OperationDao.get_active_filling_for_update( query_db, inventory.active_filling_id, + test_scope, ) if concurrent is not None and concurrent.idempotency_key == page_object.idempotency_key: # 结束首次非锁定读取建立的 RR 快照,再按最新数据范围恢复并发成功结果。 @@ -395,11 +413,13 @@ class FillingService: page_object, request_hash, current_user, + test_scope, ) warehouse = await MasterDao.get_warehouse( query_db, inventory.warehouse_id, + test_scope, for_update=True, ) if warehouse is None: @@ -416,6 +436,7 @@ class FillingService: await MasterDao.get_strain( query_db, page_object.strain_id, + test_scope, for_update=True, ) ) @@ -452,6 +473,7 @@ class FillingService: create_time=current_time, update_by=user_name, update_time=current_time, + test_batch_id=test_scope.batch_id, ) filling = await OperationDao.add_filling_record(query_db, filling) filling_id = filling.filling_id @@ -485,6 +507,7 @@ class FillingService: occurred_at=current_time, idempotency_key=page_object.idempotency_key, request_hash=request_hash, + test_batch_id=test_scope.batch_id, ) await InventoryDao.add_event(query_db, inventory, event) await query_db.commit() @@ -496,6 +519,7 @@ class FillingService: page_object, request_hash, current_user, + test_scope, ) except Exception: if not recovery_transaction_ended: diff --git a/shierfeng-fastapi-backend/module_business/service/inbound_service.py b/shierfeng-fastapi-backend/module_business/service/inbound_service.py index c0185ae..3f08e92 100644 --- a/shierfeng-fastapi-backend/module_business/service/inbound_service.py +++ b/shierfeng-fastapi-backend/module_business/service/inbound_service.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from common.vo import CrudResponseModel, PageModel -from exceptions.exception import PermissionException, ServiceException +from exceptions.exception import PermissionException, ServiceException, ServiceWarning from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.access_dao import AccessDao from module_business.dao.inventory_dao import InventoryDao @@ -16,6 +16,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizInboundItem, @@ -35,6 +36,7 @@ from module_business.entity.vo.operation_vo import ( PalletScanResponseModel, ) from module_business.service.access_service import AccessService +from module_business.service.test_batch_access_service import TestBatchAccessService class InboundService: @@ -95,9 +97,10 @@ class InboundService: query_db: AsyncSession, warehouse_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> Any: await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id) - warehouse = await MasterDao.get_warehouse(query_db, warehouse_id) + warehouse = await MasterDao.get_warehouse(query_db, warehouse_id, test_scope) if warehouse is None: raise ServiceException(message='目标仓库不存在') if warehouse.status != '0': @@ -113,6 +116,7 @@ class InboundService: warehouse_ids: set[int], hidden_warehouse_ids: set[int], require_active: bool, + test_scope: TestBatchScope, ) -> None: """锁定仓库及授权映射,并基于锁后最新状态重新校验写权限。""" warehouses: dict[int, Any] = {} @@ -121,6 +125,7 @@ class InboundService: warehouses[warehouse_id] = await MasterDao.get_warehouse( query_db, warehouse_id, + test_scope, for_update=True, ) try: @@ -152,6 +157,7 @@ class InboundService: inbound: BizInboundOrder, current_user: CurrentUserModel, *, + test_scope: TestBatchScope, require_active: bool = True, ) -> None: await cls._lock_and_authorize_warehouses( @@ -160,6 +166,7 @@ class InboundService: warehouse_ids={inbound.warehouse_id}, hidden_warehouse_ids={inbound.warehouse_id}, require_active=require_active, + test_scope=test_scope, ) @staticmethod @@ -183,10 +190,11 @@ class InboundService: cls, query_db: AsyncSession, pallet_ids: list[int], + test_scope: TestBatchScope, ) -> tuple[list[BizPalletInventory], list[BizPallet]]: ordered_ids = sorted(pallet_ids) - inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids) - pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids) + inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids, test_scope) + pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids, test_scope) expected_ids = set(ordered_ids) inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories} pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets} @@ -205,6 +213,7 @@ class InboundService: pallet_ids: list[int], user_name: str, current_time: datetime, + test_batch_id: int | None, ) -> list[BizInboundItem]: return [ BizInboundItem( @@ -212,6 +221,7 @@ class InboundService: pallet_id=pallet_id, create_by=user_name, create_time=current_time, + test_batch_id=test_batch_id, ) for pallet_id in sorted(pallet_ids) ] @@ -226,6 +236,7 @@ class InboundService: query_db: AsyncSession, query_object: InboundPageQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[InboundModel]: warehouse_ids = await cls._warehouse_scope(query_db, current_user) @@ -239,8 +250,9 @@ class InboundService: result = await OperationDao.get_inbound_list( query_db, query_object, - is_page, warehouse_ids=warehouse_ids, + scope=test_scope, + is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result models = [InboundModel.model_validate(record) for record in rows] @@ -254,11 +266,13 @@ class InboundService: query_db: AsyncSession, inbound_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> InboundModel: warehouse_ids = await cls._warehouse_scope(query_db, current_user) inbound = await OperationDao.get_inbound_by_id( query_db, inbound_id, + test_scope, warehouse_ids=warehouse_ids, ) if inbound is None: @@ -270,12 +284,14 @@ class InboundService: cls, query_db: AsyncSession, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> list[BusinessOptionModel]: warehouse_ids = await cls._warehouse_scope(query_db, current_user) allowed_ids = None if warehouse_ids is None else set(warehouse_ids) warehouses = await MasterDao.list_warehouse( query_db, WarehousePageQueryModel(status='0'), + test_scope, False, ) options = [] @@ -301,22 +317,23 @@ class InboundService: qr_token: str, query_object: InboundScanQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PalletScanResponseModel: await AccessService.assert_warehouse_access( query_db, current_user, query_object.warehouse_id, ) - warehouse = await MasterDao.get_warehouse(query_db, query_object.warehouse_id) + warehouse = await MasterDao.get_warehouse(query_db, query_object.warehouse_id, test_scope) if warehouse is None: raise ServiceException(message='目标仓库不存在') if warehouse.status != '0': raise ServiceException(message='目标仓库已停用') - pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token) + pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: raise ServiceException(message='托盘不存在') - inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id) + inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: raise ServiceException(message='托盘库存不存在') cls._validate_pallet_pair(pallet, inventory) @@ -344,22 +361,26 @@ class InboundService: query_db: AsyncSession, page_object: InboundCreateModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) _, user_name = cls._current_identity(current_user) await cls._validate_active_warehouse( query_db, page_object.warehouse_id, current_user, + test_scope, ) ordered_ids = sorted(page_object.pallet_ids) - await cls._lock_and_validate_pallets(query_db, ordered_ids) + await cls._lock_and_validate_pallets(query_db, ordered_ids, test_scope) await cls._lock_and_authorize_warehouses( query_db, current_user, warehouse_ids={page_object.warehouse_id}, hidden_warehouse_ids=set(), require_active=True, + test_scope=test_scope, ) current_time = datetime.now() @@ -371,12 +392,13 @@ class InboundService: create_time=current_time, update_by=user_name, update_time=current_time, + test_batch_id=test_scope.batch_id, ) inbound = await OperationDao.add_inbound(query_db, inbound) await OperationDao.replace_inbound_items( query_db, inbound.inbound_id, - cls._build_items(inbound.inbound_id, ordered_ids, user_name, current_time), + cls._build_items(inbound.inbound_id, ordered_ids, user_name, current_time, test_scope.batch_id), ) await query_db.commit() return cls._success('新增成功') @@ -393,13 +415,16 @@ class InboundService: query_db: AsyncSession, page_object: InboundUpdateModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) _, user_name = cls._current_identity(current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user) inbound = await OperationDao.get_inbound_for_update( query_db, page_object.inbound_id, + test_scope, warehouse_ids=warehouse_ids, ) if inbound is None: @@ -410,15 +435,17 @@ class InboundService: query_db, page_object.warehouse_id, current_user, + test_scope, ) ordered_ids = sorted(page_object.pallet_ids) - await cls._lock_and_validate_pallets(query_db, ordered_ids) + await cls._lock_and_validate_pallets(query_db, ordered_ids, test_scope) await cls._lock_and_authorize_warehouses( query_db, current_user, warehouse_ids={inbound.warehouse_id, page_object.warehouse_id}, hidden_warehouse_ids={inbound.warehouse_id}, require_active=True, + test_scope=test_scope, ) current_time = datetime.now() @@ -429,7 +456,7 @@ class InboundService: await OperationDao.replace_inbound_items( query_db, inbound.inbound_id, - cls._build_items(inbound.inbound_id, ordered_ids, user_name, current_time), + cls._build_items(inbound.inbound_id, ordered_ids, user_name, current_time, test_scope.batch_id), ) await query_db.commit() return cls._success('修改成功') @@ -446,47 +473,9 @@ class InboundService: query_db: AsyncSession, inbound_ids: list[int], current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: - try: - if not inbound_ids or any(inbound_id <= 0 for inbound_id in inbound_ids): - raise ServiceException(message='入库单ID必须为正整数') - if len(inbound_ids) != len(set(inbound_ids)): - raise ServiceException(message='入库单ID不能重复') - - ordered_ids = sorted(inbound_ids) - warehouse_ids = await cls._warehouse_scope(query_db, current_user) - locked_inbounds = [] - for inbound_id in ordered_ids: - inbound = await OperationDao.get_inbound_for_update( - query_db, - inbound_id, - warehouse_ids=warehouse_ids, - ) - if inbound is None: - cls._raise_inbound_not_accessible(current_user) - if inbound.status != DocumentStatus.DRAFT.value: - raise ServiceException(message='已确认入库单不允许删除') - locked_inbounds.append(inbound) - - locked_warehouse_ids = {inbound.warehouse_id for inbound in locked_inbounds} - await cls._lock_and_authorize_warehouses( - query_db, - current_user, - warehouse_ids=locked_warehouse_ids, - hidden_warehouse_ids=locked_warehouse_ids, - require_active=False, - ) - - for inbound_id in ordered_ids: - await OperationDao.delete_inbound(query_db, inbound_id) - await query_db.commit() - return cls._success('删除成功') - except IntegrityError: - await query_db.rollback() - raise ServiceException(message='删除入库单失败,单据状态或关联数据已变化') from None - except Exception: - await query_db.rollback() - raise + raise ServiceWarning(message='不支持单条物理删除,请通过测试数据批次清理') @staticmethod def _is_exact_confirmed( @@ -510,12 +499,14 @@ class InboundService: idempotency_key: str, request_hash: str, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: warehouse_ids = await cls._warehouse_scope(query_db, current_user) current = await OperationDao.get_inbound_by_id( query_db, inbound_id, + test_scope, warehouse_ids=warehouse_ids, ) if current is None: @@ -523,6 +514,7 @@ class InboundService: recovered = await OperationDao.get_inbound_by_confirm_idempotency_key( query_db, idempotency_key, + test_scope, warehouse_ids=[current.warehouse_id], ) if recovered is None: @@ -537,6 +529,7 @@ class InboundService: locked_recovered = await OperationDao.get_inbound_for_update( query_db, inbound_id, + test_scope, warehouse_ids=warehouse_ids, ) if locked_recovered is None: @@ -552,6 +545,7 @@ class InboundService: query_db, locked_recovered, current_user, + test_scope=test_scope, require_active=False, ) return cls._success('确认成功') @@ -565,13 +559,16 @@ class InboundService: inbound_id: int, page_object: ConfirmCommandModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) user_id, user_name = cls._current_identity(current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user) inbound = await OperationDao.get_inbound_by_id( query_db, inbound_id, + test_scope, warehouse_ids=warehouse_ids, ) if inbound is None: @@ -582,6 +579,7 @@ class InboundService: existing = await OperationDao.get_inbound_by_confirm_idempotency_key( query_db, idempotency_key, + test_scope, warehouse_ids=[inbound.warehouse_id], ) if existing is not None: @@ -601,6 +599,7 @@ class InboundService: locked_inbound = await OperationDao.get_inbound_for_update( query_db, inbound_id, + test_scope, warehouse_ids=warehouse_ids, ) if locked_inbound is None: @@ -616,6 +615,7 @@ class InboundService: query_db, locked_inbound, current_user, + test_scope=test_scope, require_active=False, ) await query_db.rollback() @@ -634,11 +634,12 @@ class InboundService: if len(pallet_ids) != len(set(pallet_ids)): raise ServiceException(message='入库单托盘不能重复') ordered_ids = sorted(pallet_ids) - inventories, _ = await cls._lock_and_validate_pallets(query_db, ordered_ids) + inventories, _ = await cls._lock_and_validate_pallets(query_db, ordered_ids, test_scope) await cls._lock_and_reauthorize_warehouse( query_db, locked_inbound, current_user, + test_scope=test_scope, ) for inventory in inventories: @@ -691,6 +692,7 @@ class InboundService: occurred_at=current_time, idempotency_key=idempotency_key, request_hash=request_hash, + test_batch_id=test_scope.batch_id, ) await InventoryDao.add_event(query_db, inventory, event) @@ -704,6 +706,7 @@ class InboundService: page_object.idempotency_key, cls._build_confirm_request_hash(inbound_id), current_user, + test_scope, ) except Exception: await query_db.rollback() diff --git a/shierfeng-fastapi-backend/module_business/service/return_service.py b/shierfeng-fastapi-backend/module_business/service/return_service.py index 858e02f..4643b5e 100644 --- a/shierfeng-fastapi-backend/module_business/service/return_service.py +++ b/shierfeng-fastapi-backend/module_business/service/return_service.py @@ -18,6 +18,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState, ReturnStatus from module_business.domain.pallet_state_machine import PalletStateMachine +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizFillingRecord, @@ -37,6 +38,7 @@ from module_business.entity.vo.operation_vo import ( ReturnScanQueryModel, ) from module_business.service.access_service import AccessService +from module_business.service.test_batch_access_service import TestBatchAccessService class ReturnService: @@ -207,6 +209,7 @@ class ReturnService: query_db: AsyncSession, query_object: ReturnPageQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[ReturnModel]: site_ids, warehouse_ids = await cls._scopes(query_db, current_user) @@ -224,9 +227,10 @@ class ReturnService: result = await OperationDao.get_return_list( query_db, query_object, - is_page, site_ids=site_ids, warehouse_ids=warehouse_ids, + scope=test_scope, + is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result models = [ReturnModel.model_validate(record) for record in rows] @@ -240,11 +244,13 @@ class ReturnService: query_db: AsyncSession, return_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> ReturnModel: site_ids, warehouse_ids = await cls._scopes(query_db, current_user) return_order = await OperationDao.get_return_by_id( query_db, return_id, + test_scope, site_ids=site_ids, warehouse_ids=warehouse_ids, ) @@ -257,11 +263,13 @@ class ReturnService: cls, query_db: AsyncSession, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> list[BusinessOptionModel]: cls._current_identity(current_user) warehouses = await MasterDao.list_warehouse( query_db, WarehousePageQueryModel(status='0'), + test_scope, False, ) options = [ @@ -333,11 +341,12 @@ class ReturnService: query_db: AsyncSession, qr_token: str, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PalletScanResponseModel: - pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token) + pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) - inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id) + inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None or inventory.site_id is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在或不在客户现场') await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id) @@ -345,7 +354,7 @@ class ReturnService: filling = None if inventory.active_filling_id is not None: - filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id) + filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在') cls._validate_active_filling(filling, inventory) @@ -358,12 +367,14 @@ class ReturnService: qr_token: str, return_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PalletScanResponseModel: warehouse_ids = await cls._warehouse_scope(query_db, current_user) confirm_site_ids = None if warehouse_ids is None else [] return_order = await OperationDao.get_return_by_id( query_db, return_id, + test_scope, site_ids=confirm_site_ids, warehouse_ids=warehouse_ids, ) @@ -372,13 +383,13 @@ class ReturnService: if return_order.status != ReturnStatus.REQUESTED.value: raise ServiceException(message='退回单当前状态不允许确认扫码') - pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token) + pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: 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: cls._raise_pallet_not_accessible(current_user, '扫码托盘不属于该退回单') - inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id) + inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') item = matching_items[0] @@ -386,7 +397,7 @@ class ReturnService: filling = None if inventory.active_filling_id is not None: - filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id) + filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在') cls._validate_active_filling(filling, inventory) @@ -424,12 +435,13 @@ class ReturnService: qr_token: str, query_object: ReturnScanQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PalletScanResponseModel: normalized_qr = qr_token.strip() if not normalized_qr: raise ServiceException(message='托盘二维码不能为空') if query_object.mode == 'request': - return await cls._scan_request_pallet(query_db, normalized_qr, current_user) + return await cls._scan_request_pallet(query_db, normalized_qr, current_user, test_scope) if query_object.return_id is None: raise ServiceException(message='确认退回扫码时必须提供退回单ID') return await cls._scan_confirm_pallet( @@ -437,6 +449,7 @@ class ReturnService: normalized_qr, query_object.return_id, current_user, + test_scope, ) @classmethod @@ -447,6 +460,7 @@ class ReturnService: source_site_id: int, current_user: CurrentUserModel, inventories: list[BizPalletInventory], + test_scope: TestBatchScope, ) -> tuple[ list[BizPalletInventory], list[BizPallet], @@ -466,7 +480,7 @@ class ReturnService: ): raise ServiceException(message='托盘当前状态不允许发起退回') - pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids) + pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids, test_scope) 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, '存在不存在或库存缺失的托盘') @@ -481,7 +495,9 @@ class ReturnService: } ) for filling_id in filling_ids: - filling_by_id[filling_id] = await OperationDao.get_active_filling_for_update(query_db, filling_id) + filling_by_id[filling_id] = await OperationDao.get_active_filling_for_update( + query_db, filling_id, test_scope + ) return ordered_inventories, ordered_pallets, filling_by_id @classmethod @@ -513,6 +529,7 @@ class ReturnService: inventories: list[BizPalletInventory], user_name: str, current_time: datetime, + test_batch_id: int | None, ) -> list[BizReturnItem]: return [ BizReturnItem( @@ -522,6 +539,7 @@ class ReturnService: remaining_packages_snapshot=inventory.remaining_packages, create_by=user_name, create_time=current_time, + test_batch_id=test_batch_id, ) for inventory in inventories ] @@ -532,10 +550,12 @@ class ReturnService: query_db: AsyncSession, page_object: ReturnRequestModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> None: warehouse = await MasterDao.get_warehouse( query_db, page_object.target_warehouse_id, + test_scope, for_update=True, ) if warehouse is None: @@ -543,7 +563,7 @@ class ReturnService: if warehouse.status != '0': raise ServiceException(message='目标仓库已停用') - site = await MasterDao.get_site(query_db, page_object.source_site_id, for_update=True) + site = await MasterDao.get_site(query_db, page_object.source_site_id, test_scope, for_update=True) await AccessService.assert_site_access( query_db, current_user, @@ -561,9 +581,11 @@ class ReturnService: query_db: AsyncSession, return_order: BizReturnOrder, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> None: - site = await MasterDao.get_site(query_db, return_order.source_site_id, for_update=True) + site = await MasterDao.get_site(query_db, return_order.source_site_id, test_scope, for_update=True) try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) await AccessService.assert_site_access( query_db, current_user, @@ -582,6 +604,7 @@ class ReturnService: page_object: ReturnRequestModel, request_hash: str, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: site_ids = await cls._site_scope(query_db, current_user) @@ -593,12 +616,14 @@ class ReturnService: page_object.idempotency_key, source_site_id=page_object.source_site_id, target_warehouse_id=page_object.target_warehouse_id, + scope=test_scope, ) if recovered is None or not cls._is_exact_request_result(recovered, page_object, request_hash): raise ServiceException(message='申请退回失败,幂等冲突') locked = await OperationDao.get_return_for_update( query_db, recovered.return_id, + test_scope, site_ids=site_ids, warehouse_ids=request_warehouse_ids, ) @@ -611,7 +636,7 @@ class ReturnService: require_items=True, ): raise ServiceException(message='申请退回失败,幂等键已被其他请求使用') - await cls._lock_and_reauthorize_request_site(query_db, locked, current_user) + await cls._lock_and_reauthorize_request_site(query_db, locked, current_user, test_scope) return cls._success('申请成功', locked.return_id) finally: await query_db.rollback() @@ -622,8 +647,10 @@ class ReturnService: query_db: AsyncSession, page_object: ReturnRequestModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) 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) @@ -644,6 +671,7 @@ class ReturnService: page_object.idempotency_key, source_site_id=page_object.source_site_id, target_warehouse_id=page_object.target_warehouse_id, + scope=test_scope, ) if existing is not None and not cls._is_exact_request_result(existing, page_object, request_hash): raise ServiceException(message='申请退回失败,幂等键已被其他请求使用') @@ -654,6 +682,7 @@ class ReturnService: locked_existing = await OperationDao.get_return_for_update( query_db, preflight_existing_id, + test_scope, site_ids=preflight_site_ids, warehouse_ids=request_warehouse_ids, ) @@ -666,12 +695,12 @@ class ReturnService: require_items=True, ): raise ServiceException(message='申请退回失败,幂等键已被其他请求使用') - await cls._lock_and_reauthorize_request_site(query_db, locked_existing, current_user) + await cls._lock_and_reauthorize_request_site(query_db, locked_existing, current_user, test_scope) return_id = locked_existing.return_id await query_db.rollback() return cls._success('申请成功', return_id) - inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids) + inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids, test_scope) try: await AccessService.assert_site_access( query_db, @@ -688,12 +717,13 @@ class ReturnService: page_object.idempotency_key, source_site_id=page_object.source_site_id, target_warehouse_id=page_object.target_warehouse_id, + scope=test_scope, ) 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 cls._lock_and_reauthorize_request_site(query_db, fresh_existing, current_user, test_scope) await query_db.rollback() return cls._success('申请成功', fresh_existing_id) @@ -703,8 +733,9 @@ class ReturnService: page_object.source_site_id, current_user, inventories, + test_scope, ) - await cls._lock_new_request_route(query_db, page_object, current_user) + await cls._lock_new_request_route(query_db, page_object, current_user, test_scope) cls._validate_locked_request_pallets( inventories, pallets, @@ -732,12 +763,13 @@ class ReturnService: create_time=current_time, update_by=user_name, update_time=current_time, + test_batch_id=test_scope.batch_id, ) 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_id, inventories, user_name, current_time), + cls._build_return_items(return_id, inventories, user_name, current_time, test_scope.batch_id), ) for inventory in inventories: @@ -768,6 +800,7 @@ class ReturnService: occurred_at=current_time, idempotency_key=page_object.idempotency_key, request_hash=request_hash, + test_batch_id=test_scope.batch_id, ) await InventoryDao.add_event(query_db, inventory, event) @@ -825,10 +858,11 @@ class ReturnService: query_db: AsyncSession, return_order: BizReturnOrder, items: list[BizReturnItem], + test_scope: TestBatchScope, ) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord]]: ordered_ids = sorted(item.pallet_id for item in items) - inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids) - pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids) + inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids, test_scope) + pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids, test_scope) inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories} pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets} item_by_id = {item.pallet_id: item for item in items} @@ -857,7 +891,7 @@ class ReturnService: } ) for filling_id in filling_ids: - filling = await OperationDao.get_active_filling_for_update(query_db, filling_id) + filling = await OperationDao.get_active_filling_for_update(query_db, filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在或已关闭') filling_by_id[filling_id] = filling @@ -873,11 +907,13 @@ class ReturnService: return_order: BizReturnOrder, current_user: CurrentUserModel, *, + test_scope: TestBatchScope, require_active: bool, ) -> None: warehouse = await MasterDao.get_warehouse( query_db, return_order.target_warehouse_id, + test_scope, for_update=True, ) try: @@ -902,6 +938,7 @@ class ReturnService: page_object: ReturnConfirmModel, request_hash: str, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: warehouse_ids = await cls._warehouse_scope(query_db, current_user) @@ -909,6 +946,7 @@ class ReturnService: route = await OperationDao.get_return_by_id( query_db, return_id, + test_scope, site_ids=confirm_site_ids, warehouse_ids=warehouse_ids, ) @@ -919,6 +957,7 @@ class ReturnService: page_object.idempotency_key, source_site_id=route.source_site_id, target_warehouse_id=route.target_warehouse_id, + scope=test_scope, ) if recovered is None: raise ServiceException(message='确认退回失败,幂等冲突') @@ -927,6 +966,7 @@ class ReturnService: locked = await OperationDao.get_return_for_update( query_db, return_id, + test_scope, site_ids=confirm_site_ids, warehouse_ids=warehouse_ids, ) @@ -944,6 +984,7 @@ class ReturnService: query_db, locked, current_user, + test_scope=test_scope, require_active=False, ) return cls._success('确认成功', return_id) @@ -957,8 +998,10 @@ class ReturnService: return_id: int, page_object: ReturnConfirmModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) user_id, user_name = cls._current_identity(current_user) scanned_ids = cls._validate_pallet_ids(page_object.scanned_pallet_ids) request_hash = cls._build_confirm_request_hash(return_id, page_object) @@ -967,6 +1010,7 @@ class ReturnService: return_order = await OperationDao.get_return_by_id( query_db, return_id, + test_scope, site_ids=confirm_site_ids, warehouse_ids=warehouse_ids, ) @@ -985,6 +1029,7 @@ class ReturnService: page_object.idempotency_key, source_site_id=return_order.source_site_id, target_warehouse_id=return_order.target_warehouse_id, + scope=test_scope, ) if existing is not None and not cls._is_exact_confirm_result( existing, @@ -998,6 +1043,7 @@ class ReturnService: locked = await OperationDao.get_return_for_update( query_db, return_id, + test_scope, site_ids=confirm_site_ids, warehouse_ids=warehouse_ids, ) @@ -1015,6 +1061,7 @@ class ReturnService: query_db, locked, current_user, + test_scope=test_scope, require_active=False, ) await query_db.rollback() @@ -1034,11 +1081,12 @@ class ReturnService: if scanned_ids != sorted(item_ids): raise ServiceException(message='确认扫码托盘集合与退回单明细不一致') - inventories, _ = await cls._lock_confirm_pallets(query_db, locked, items) + inventories, _ = await cls._lock_confirm_pallets(query_db, locked, items, test_scope) await cls._lock_and_reauthorize_confirm_warehouse( query_db, locked, current_user, + test_scope=test_scope, require_active=True, ) for inventory in inventories: @@ -1096,6 +1144,7 @@ class ReturnService: occurred_at=current_time, idempotency_key=page_object.idempotency_key, request_hash=request_hash, + test_batch_id=test_scope.batch_id, ) await InventoryDao.add_event(query_db, inventory, event) @@ -1109,6 +1158,7 @@ class ReturnService: page_object, cls._build_confirm_request_hash(return_id, page_object), current_user, + test_scope, ) except Exception: await query_db.rollback() diff --git a/shierfeng-fastapi-backend/module_business/service/shipment_service.py b/shierfeng-fastapi-backend/module_business/service/shipment_service.py index b572fdb..4c99e6a 100644 --- a/shierfeng-fastapi-backend/module_business/service/shipment_service.py +++ b/shierfeng-fastapi-backend/module_business/service/shipment_service.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from common.vo import CrudResponseModel, PageModel -from exceptions.exception import PermissionException, ServiceException +from exceptions.exception import PermissionException, ServiceException, ServiceWarning from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.access_dao import AccessDao from module_business.dao.inventory_dao import InventoryDao @@ -16,6 +16,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizFillingRecord, @@ -36,6 +37,7 @@ from module_business.entity.vo.operation_vo import ( ShipmentUpdateModel, ) from module_business.service.access_service import AccessService +from module_business.service.test_batch_access_service import TestBatchAccessService class ShipmentService: @@ -97,15 +99,16 @@ class ShipmentService: source_warehouse_id: int, target_site_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> tuple[Any, Any]: await AccessService.assert_warehouse_access(query_db, current_user, source_warehouse_id) - warehouse = await MasterDao.get_warehouse(query_db, source_warehouse_id) + warehouse = await MasterDao.get_warehouse(query_db, source_warehouse_id, test_scope) if warehouse is None: raise ServiceException(message='来源仓库不存在') if warehouse.status != '0': raise ServiceException(message='来源仓库已停用') await AccessService.assert_site_access(query_db, current_user, target_site_id) - site = await MasterDao.get_site(query_db, target_site_id) + site = await MasterDao.get_site(query_db, target_site_id, test_scope) if site is None: raise ServiceException(message='目标现场不存在') if site.status != '0': @@ -158,10 +161,11 @@ class ShipmentService: query_db: AsyncSession, pallet_ids: list[int], source_warehouse_id: int, + test_scope: TestBatchScope, ) -> tuple[list[BizPalletInventory], list[BizPallet], list[BizFillingRecord]]: 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) + inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids, test_scope) + pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids, test_scope) if ( len(inventories) != len(ordered_ids) or len(pallets) != len(ordered_ids) @@ -181,7 +185,7 @@ class ShipmentService: fillings = [] for filling_id in sorted(set(active_filling_ids)): - filling = await OperationDao.get_active_filling_for_update(query_db, filling_id) + filling = await OperationDao.get_active_filling_for_update(query_db, filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在或已关闭') fillings.append(filling) @@ -207,6 +211,7 @@ class ShipmentService: pallet_ids: list[int], user_name: str, current_time: datetime, + test_batch_id: int | None, ) -> list[BizShipmentItem]: return [ BizShipmentItem( @@ -216,6 +221,7 @@ class ShipmentService: package_qty=None, create_by=user_name, create_time=current_time, + test_batch_id=test_batch_id, ) for pallet_id in sorted(pallet_ids) ] @@ -239,6 +245,7 @@ class ShipmentService: query_db: AsyncSession, query_object: ShipmentPageQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[ShipmentModel]: warehouse_ids, site_ids = await cls._scopes(query_db, current_user) @@ -268,9 +275,10 @@ class ShipmentService: result = await OperationDao.get_shipment_list( query_db, query_object, - is_page, warehouse_ids=warehouse_ids, site_ids=site_ids, + scope=test_scope, + is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result models = [ShipmentModel.model_validate(record) for record in rows] @@ -284,11 +292,13 @@ class ShipmentService: query_db: AsyncSession, shipment_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> ShipmentModel: warehouse_ids, site_ids = await cls._scopes(query_db, current_user) shipment = await OperationDao.get_shipment_by_id( query_db, shipment_id, + test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids, ) @@ -301,12 +311,14 @@ class ShipmentService: cls, query_db: AsyncSession, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> list[BusinessOptionModel]: warehouse_ids, _ = await cls._scopes(query_db, current_user) allowed_ids = None if warehouse_ids is None else set(warehouse_ids) warehouses = await MasterDao.list_warehouse( query_db, WarehousePageQueryModel(status='0'), + test_scope, False, ) options = [] @@ -330,12 +342,14 @@ class ShipmentService: cls, query_db: AsyncSession, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> list[BusinessOptionModel]: _, site_ids = await cls._scopes(query_db, current_user) allowed_ids = None if site_ids is None else set(site_ids) sites = await MasterDao.list_site( query_db, SitePageQueryModel(status='0'), + test_scope, False, ) options = [] @@ -388,13 +402,14 @@ class ShipmentService: qr_token: str, query_object: ShipmentScanQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PalletScanResponseModel: await AccessService.assert_warehouse_access( query_db, current_user, query_object.source_warehouse_id, ) - warehouse = await MasterDao.get_warehouse(query_db, query_object.source_warehouse_id) + warehouse = await MasterDao.get_warehouse(query_db, query_object.source_warehouse_id, test_scope) if warehouse is None: raise ServiceException(message='来源仓库不存在') if warehouse.status != '0': @@ -404,16 +419,16 @@ class ShipmentService: current_user, query_object.target_site_id, ) - site = await MasterDao.get_site(query_db, query_object.target_site_id) + site = await MasterDao.get_site(query_db, query_object.target_site_id, test_scope) if site is None: raise ServiceException(message='目标现场不存在') if site.status != '0': raise ServiceException(message='目标现场已停用') - pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token) + pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) - inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id) + inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') if inventory.pallet_id != pallet.pallet_id or inventory.warehouse_id != query_object.source_warehouse_id: @@ -422,7 +437,7 @@ class ShipmentService: cls._raise_pallet_not_accessible(current_user, '托盘已在客户现场') if inventory.active_filling_id is None: raise ServiceException(message='托盘不存在活动装填周期') - filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id) + filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在') cls._validate_scan_pallet( @@ -455,20 +470,24 @@ class ShipmentService: query_db: AsyncSession, page_object: ShipmentCreateModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) _, user_name = cls._current_identity(current_user) await cls._validate_active_route( query_db, page_object.source_warehouse_id, page_object.target_site_id, current_user, + test_scope, ) ordered_ids = cls._validate_pallet_ids(page_object.pallet_ids) await cls._lock_and_validate_pallets( query_db, ordered_ids, page_object.source_warehouse_id, + test_scope, ) await cls._lock_and_authorize_routes( query_db, @@ -478,6 +497,7 @@ class ShipmentService: hidden_warehouse_ids=set(), hidden_site_ids=set(), require_active=True, + test_scope=test_scope, ) current_time = datetime.now() @@ -490,12 +510,13 @@ class ShipmentService: create_time=current_time, update_by=user_name, update_time=current_time, + test_batch_id=test_scope.batch_id, ) shipment = await OperationDao.add_shipment(query_db, shipment) await OperationDao.replace_shipment_items( query_db, shipment.shipment_id, - cls._build_items(shipment.shipment_id, ordered_ids, user_name, current_time), + cls._build_items(shipment.shipment_id, ordered_ids, user_name, current_time, test_scope.batch_id), ) await query_db.commit() return cls._success('新增成功') @@ -512,13 +533,16 @@ class ShipmentService: query_db: AsyncSession, page_object: ShipmentUpdateModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) _, user_name = cls._current_identity(current_user) warehouse_ids, site_ids = await cls._scopes(query_db, current_user) shipment = await OperationDao.get_shipment_for_update( query_db, page_object.shipment_id, + test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids, ) @@ -534,12 +558,14 @@ class ShipmentService: page_object.source_warehouse_id, page_object.target_site_id, current_user, + test_scope, ) ordered_ids = cls._validate_pallet_ids(page_object.pallet_ids) await cls._lock_and_validate_pallets( query_db, ordered_ids, page_object.source_warehouse_id, + test_scope, ) await cls._lock_and_authorize_routes( query_db, @@ -555,6 +581,7 @@ class ShipmentService: hidden_warehouse_ids={shipment.source_warehouse_id}, hidden_site_ids={shipment.target_site_id}, require_active=True, + test_scope=test_scope, ) current_time = datetime.now() @@ -566,7 +593,7 @@ class ShipmentService: await OperationDao.replace_shipment_items( query_db, shipment.shipment_id, - cls._build_items(shipment.shipment_id, ordered_ids, user_name, current_time), + cls._build_items(shipment.shipment_id, ordered_ids, user_name, current_time, test_scope.batch_id), ) await query_db.commit() return cls._success('修改成功') @@ -583,53 +610,9 @@ class ShipmentService: query_db: AsyncSession, shipment_ids: list[int], current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: - try: - if not shipment_ids or any( - type(shipment_id) is not int or shipment_id <= 0 for shipment_id in shipment_ids - ): - raise ServiceException(message='发货单ID必须为正整数') - if len(shipment_ids) != len(set(shipment_ids)): - raise ServiceException(message='发货单ID不能重复') - - ordered_ids = sorted(shipment_ids) - warehouse_ids, site_ids = await cls._scopes(query_db, current_user) - locked_shipments = [] - for shipment_id in ordered_ids: - shipment = await OperationDao.get_shipment_for_update( - query_db, - shipment_id, - warehouse_ids=warehouse_ids, - site_ids=site_ids, - ) - if shipment is None: - cls._raise_shipment_not_accessible(current_user) - if shipment.status != DocumentStatus.DRAFT.value: - raise ServiceException(message='已确认发货单不允许删除') - locked_shipments.append(shipment) - - route_warehouse_ids = {shipment.source_warehouse_id for shipment in locked_shipments} - route_site_ids = {shipment.target_site_id for shipment in locked_shipments} - await cls._lock_and_authorize_routes( - query_db, - current_user, - warehouse_ids=route_warehouse_ids, - site_ids=route_site_ids, - hidden_warehouse_ids=route_warehouse_ids, - hidden_site_ids=route_site_ids, - require_active=False, - ) - - for shipment_id in ordered_ids: - await OperationDao.delete_shipment(query_db, shipment_id) - await query_db.commit() - return cls._success('删除成功') - except IntegrityError: - await query_db.rollback() - raise ServiceException(message='删除发货单失败,单据状态或关联数据已变化') from None - except Exception: - await query_db.rollback() - raise + raise ServiceWarning(message='不支持单条物理删除,请通过测试数据批次清理') @staticmethod def _is_exact_confirmed( @@ -656,6 +639,7 @@ class ShipmentService: hidden_warehouse_ids: set[int], hidden_site_ids: set[int], require_active: bool, + test_scope: TestBatchScope, ) -> None: warehouses: dict[int, Any] = {} warehouse_permission_errors: dict[int, PermissionException] = {} @@ -663,6 +647,7 @@ class ShipmentService: warehouse = await MasterDao.get_warehouse( query_db, warehouse_id, + test_scope, for_update=True, ) warehouses[warehouse_id] = warehouse @@ -682,6 +667,7 @@ class ShipmentService: site = await MasterDao.get_site( query_db, site_id, + test_scope, for_update=True, ) sites[site_id] = site @@ -727,6 +713,7 @@ class ShipmentService: shipment: BizShipmentOrder, current_user: CurrentUserModel, *, + test_scope: TestBatchScope, require_active: bool = True, ) -> None: await cls._lock_and_authorize_routes( @@ -737,6 +724,7 @@ class ShipmentService: hidden_warehouse_ids={shipment.source_warehouse_id}, hidden_site_ids={shipment.target_site_id}, require_active=require_active, + test_scope=test_scope, ) @classmethod @@ -747,12 +735,14 @@ class ShipmentService: idempotency_key: str, request_hash: str, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: warehouse_ids, site_ids = await cls._scopes(query_db, current_user) current = await OperationDao.get_shipment_by_id( query_db, shipment_id, + test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids, ) @@ -761,6 +751,7 @@ class ShipmentService: recovered = await OperationDao.get_shipment_by_confirm_idempotency_key( query_db, idempotency_key, + test_scope, warehouse_ids=[current.source_warehouse_id], site_ids=[current.target_site_id], ) @@ -776,6 +767,7 @@ class ShipmentService: locked_recovered = await OperationDao.get_shipment_for_update( query_db, shipment_id, + test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids, ) @@ -792,6 +784,7 @@ class ShipmentService: query_db, locked_recovered, current_user, + test_scope=test_scope, require_active=False, ) return cls._success('确认成功') @@ -805,13 +798,16 @@ class ShipmentService: shipment_id: int, page_object: ConfirmCommandModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) user_id, user_name = cls._current_identity(current_user) warehouse_ids, site_ids = await cls._scopes(query_db, current_user) shipment = await OperationDao.get_shipment_by_id( query_db, shipment_id, + test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids, ) @@ -823,6 +819,7 @@ class ShipmentService: existing = await OperationDao.get_shipment_by_confirm_idempotency_key( query_db, idempotency_key, + test_scope, warehouse_ids=[shipment.source_warehouse_id], site_ids=[shipment.target_site_id], ) @@ -837,6 +834,7 @@ class ShipmentService: locked_shipment = await OperationDao.get_shipment_for_update( query_db, shipment_id, + test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids, ) @@ -853,6 +851,7 @@ class ShipmentService: query_db, locked_shipment, current_user, + test_scope=test_scope, require_active=False, ) await query_db.rollback() @@ -874,8 +873,9 @@ class ShipmentService: query_db, ordered_ids, locked_shipment.source_warehouse_id, + test_scope, ) - await cls._lock_and_reauthorize_route(query_db, locked_shipment, current_user) + await cls._lock_and_reauthorize_route(query_db, locked_shipment, current_user, test_scope=test_scope) for inventory in inventories: PalletStateMachine.ensure_transition( @@ -930,6 +930,7 @@ class ShipmentService: occurred_at=current_time, idempotency_key=idempotency_key, request_hash=request_hash, + test_batch_id=test_scope.batch_id, ) await InventoryDao.add_event(query_db, inventory, event) @@ -943,6 +944,7 @@ class ShipmentService: page_object.idempotency_key, cls._build_confirm_request_hash(shipment_id), current_user, + test_scope, ) except Exception: await query_db.rollback() diff --git a/shierfeng-fastapi-backend/module_business/service/usage_service.py b/shierfeng-fastapi-backend/module_business/service/usage_service.py index f64d876..f38f730 100644 --- a/shierfeng-fastapi-backend/module_business/service/usage_service.py +++ b/shierfeng-fastapi-backend/module_business/service/usage_service.py @@ -18,6 +18,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.operation_do import BizPalletEvent, BizUsageRecord from module_business.entity.vo.master_vo import LinePageQueryModel from module_business.entity.vo.operation_vo import ( @@ -29,6 +30,7 @@ from module_business.entity.vo.operation_vo import ( UsageSuccessResponseModel, ) from module_business.service.access_service import AccessService +from module_business.service.test_batch_access_service import TestBatchAccessService class UsageService: @@ -147,8 +149,9 @@ class UsageService: query_db: AsyncSession, site_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> None: - site = await MasterDao.get_site(query_db, site_id, for_update=True) + site = await MasterDao.get_site(query_db, site_id, test_scope, for_update=True) try: await AccessService.assert_site_access( query_db, @@ -171,6 +174,7 @@ class UsageService: request_hash: str, current_user: CurrentUserModel, expected_site_id: int, + test_scope: TestBatchScope, ) -> CrudResponseModel: try: site_ids = await cls._site_scope(query_db, current_user) @@ -179,6 +183,7 @@ class UsageService: recovered = await OperationDao.get_usage_by_idempotency_key( query_db, page_object.idempotency_key, + test_scope, site_ids=[expected_site_id], ) if recovered is None: @@ -191,6 +196,7 @@ class UsageService: query_db, recovered.site_id, current_user, + test_scope, ) return cls._success(recovered) finally: @@ -202,6 +208,7 @@ class UsageService: query_db: AsyncSession, query_object: UsagePageQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[UsageModel]: site_ids = await cls._site_scope(query_db, current_user) @@ -220,8 +227,9 @@ class UsageService: result = await OperationDao.get_usage_list( query_db, query_object, - is_page, site_ids=site_ids, + scope=test_scope, + is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result models = [UsageModel.model_validate(record) for record in rows] @@ -235,11 +243,13 @@ class UsageService: query_db: AsyncSession, usage_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> UsageModel: site_ids = await cls._site_scope(query_db, current_user) usage = await OperationDao.get_usage_by_id( query_db, usage_id, + test_scope, site_ids=site_ids, ) if usage is None: @@ -252,10 +262,11 @@ class UsageService: query_db: AsyncSession, site_id: int, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> list[BusinessOptionModel]: cls._current_identity(current_user) await AccessService.assert_site_access(query_db, current_user, site_id) - site = await MasterDao.get_site(query_db, site_id) + site = await MasterDao.get_site(query_db, site_id, test_scope) if site is None: raise ServiceException(message='客户现场不存在') if site.status != '0': @@ -263,6 +274,7 @@ class UsageService: lines = await MasterDao.list_line( query_db, LinePageQueryModel(siteId=site_id, status='0'), + test_scope, False, ) options = [ @@ -282,12 +294,13 @@ class UsageService: query_db: AsyncSession, qr_token: str, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PalletScanResponseModel: normalized_qr = qr_token.strip() - pallet = await InventoryDao.get_pallet_by_qr_token(query_db, normalized_qr) + pallet = await InventoryDao.get_pallet_by_qr_token(query_db, normalized_qr, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) - inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id) + inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') if inventory.site_id is None: @@ -311,7 +324,7 @@ class UsageService: or inventory.remaining_packages > pallet.capacity_packages ): raise ServiceException(message='托盘当前状态不允许使用') - filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id) + filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope) if filling is None: raise ServiceException(message='托盘活动装填周期不存在') if ( @@ -345,14 +358,16 @@ class UsageService: query_db: AsyncSession, page_object: UsageCreateModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> CrudResponseModel: expected_site_id: int | None = None try: + await TestBatchAccessService.lock_active_batch(query_db, test_scope) request_hash = cls._build_request_hash(page_object) user_id, user_name = cls._current_identity(current_user) preflight_site_ids = await cls._site_scope(query_db, current_user) # 产线归属是请求内稳定路由;锁事务仍以库存中的实际现场复核。 - preflight_line = await MasterDao.get_line(query_db, page_object.line_id) + preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope) preflight_site_id = preflight_line.site_id if preflight_line is not None else None existing = None if preflight_site_id is not None and ( @@ -361,6 +376,7 @@ class UsageService: existing = await OperationDao.get_usage_by_idempotency_key( query_db, page_object.idempotency_key, + test_scope, site_ids=[preflight_site_id], ) if existing is not None: @@ -372,6 +388,7 @@ class UsageService: query_db, existing.site_id, current_user, + test_scope, ) result = cls._success(existing) await query_db.rollback() @@ -380,6 +397,7 @@ class UsageService: scanned_pallet = await InventoryDao.get_pallet_by_qr_token( query_db, page_object.qr_token.strip(), + test_scope, ) if scanned_pallet is None: cls._raise_pallet_not_accessible(current_user) @@ -389,6 +407,7 @@ class UsageService: inventory = await InventoryDao.get_inventory_for_update( query_db, scanned_pallet_id, + test_scope, ) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') @@ -403,6 +422,7 @@ class UsageService: locked_pallets = await InventoryDao.get_pallets_by_ids( query_db, [inventory.pallet_id], + test_scope, ) if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != inventory.pallet_id: cls._raise_pallet_not_accessible(current_user) @@ -412,6 +432,7 @@ class UsageService: await OperationDao.get_active_filling_for_update( query_db, inventory.active_filling_id, + test_scope, ) if inventory.active_filling_id is not None else None @@ -419,6 +440,7 @@ class UsageService: locked_existing = await OperationDao.get_usage_by_idempotency_key( query_db, page_object.idempotency_key, + test_scope, site_ids=[inventory.site_id], ) if locked_existing is not None: @@ -432,6 +454,7 @@ class UsageService: query_db, locked_existing.site_id, current_user, + test_scope, ) result = cls._success(locked_existing) await query_db.rollback() @@ -442,7 +465,7 @@ class UsageService: if filling is None: raise ServiceException(message='托盘活动装填周期不存在或已关闭') - site = await MasterDao.get_site(query_db, inventory.site_id, for_update=True) + site = await MasterDao.get_site(query_db, inventory.site_id, test_scope, for_update=True) await cls._assert_pallet_site_access( query_db, current_user, @@ -453,7 +476,7 @@ class UsageService: raise ServiceException(message='客户现场不存在') if site.status != '0': raise ServiceException(message='客户现场已停用') - line = await MasterDao.get_line(query_db, page_object.line_id, for_update=True) + line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True) if line is None: raise ServiceException(message='产线不存在') if line.status != '0': @@ -512,6 +535,7 @@ class UsageService: request_hash=request_hash, operator_id=user_id, occurred_at=current_time, + test_batch_id=test_scope.batch_id, ) usage = await OperationDao.add_usage_record(query_db, usage) usage_id = usage.usage_id @@ -553,6 +577,7 @@ class UsageService: occurred_at=current_time, idempotency_key=page_object.idempotency_key, request_hash=request_hash, + test_batch_id=test_scope.batch_id, ) await InventoryDao.add_event(query_db, inventory, event) await query_db.commit() @@ -567,6 +592,7 @@ class UsageService: request_hash, current_user, expected_site_id, + test_scope, ) except Exception: await query_db.rollback() diff --git a/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py b/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py index f0c3790..380a1cc 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py @@ -25,6 +25,7 @@ from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet, BizStrain from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory from module_business.entity.vo.operation_vo import ( @@ -160,13 +161,24 @@ def make_filling( class TestFillingPublicContract(unittest.TestCase): + def test_public_business_methods_require_explicit_test_scope(self) -> None: + for method_name in ( + 'get_filling_list_services', + 'filling_detail_services', + 'get_strain_options_services', + 'scan_filling_pallet_services', + 'fill_pallet_services', + ): + with self.subTest(method=method_name): + self.assertIn('test_scope', inspect.signature(getattr(FillingService, method_name)).parameters) + def test_service_and_controller_are_available(self) -> None: self.assertIsNotNone(FillingService) self.assertIsNotNone(filling_controller_module.filling_controller) def test_fill_signature_and_types_are_exact(self) -> None: signature = inspect.signature(FillingService.fill_pallet_services) - self.assertEqual(('query_db', 'page_object', 'current_user'), tuple(signature.parameters)) + self.assertEqual(('query_db', 'page_object', 'current_user', 'test_scope'), tuple(signature.parameters)) hints = get_type_hints(FillingService.fill_pallet_services) self.assertIs(AsyncSession, hints['query_db']) self.assertIs(FillingCreateModel, hints['page_object']) @@ -217,9 +229,15 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[9, 7, 7])), patch.object(OperationDao, 'get_filling_list', new=get_list), ): - result = await FillingService.get_filling_list_services(self.db, query, self.user) + result = await FillingService.get_filling_list_services(self.db, query, self.user, TestBatchScope.formal()) - get_list.assert_awaited_once_with(self.db, query, True, warehouse_ids=[7, 9]) + get_list.assert_awaited_once_with( + self.db, + query, + warehouse_ids=[7, 9], + scope=TestBatchScope.formal(), + is_page=True, + ) self.assertIsInstance(result.rows[0], FillingModel) self.assertEqual(1, result.total) @@ -231,9 +249,15 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])), patch.object(OperationDao, 'get_filling_list', new=get_list), ): - result = await FillingService.get_filling_list_services(self.db, query, self.user) + result = await FillingService.get_filling_list_services(self.db, query, self.user, TestBatchScope.formal()) - get_list.assert_awaited_once_with(self.db, query, True, warehouse_ids=[]) + get_list.assert_awaited_once_with( + self.db, + query, + warehouse_ids=[], + scope=TestBatchScope.formal(), + is_page=True, + ) self.assertEqual([], result.rows) self.assertEqual(0, result.total) @@ -245,7 +269,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_filling_list', new=get_list), self.assertRaises(PermissionException), ): - await FillingService.get_filling_list_services(self.db, query, self.user) + await FillingService.get_filling_list_services(self.db, query, self.user, TestBatchScope.formal()) get_list.assert_not_awaited() async def test_admin_list_is_unscoped(self) -> None: @@ -256,9 +280,17 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), patch.object(OperationDao, 'get_filling_list', new=get_list), ): - result = await FillingService.get_filling_list_services(self.db, query, make_user(1), is_page=False) + result = await FillingService.get_filling_list_services( + self.db, query, make_user(1), TestBatchScope.formal(), is_page=False + ) scope.assert_not_awaited() - get_list.assert_awaited_once_with(self.db, query, False, warehouse_ids=None) + get_list.assert_awaited_once_with( + self.db, + query, + warehouse_ids=None, + scope=TestBatchScope.formal(), + is_page=False, + ) self.assertEqual([], result) async def test_detail_is_scoped_before_lookup_and_hides_missing_for_normal_user(self) -> None: @@ -268,8 +300,8 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_filling_by_id', new=get_detail), self.assertRaises(PermissionException) as raised, ): - await FillingService.filling_detail_services(self.db, 31, self.user) - get_detail.assert_awaited_once_with(self.db, 31, warehouse_ids=[7]) + await FillingService.filling_detail_services(self.db, 31, self.user, TestBatchScope.formal()) + get_detail.assert_awaited_once_with(self.db, 31, TestBatchScope.formal(), warehouse_ids=[7]) self.assertEqual('无权访问该装填记录', raised.exception.message) async def test_admin_detail_keeps_precise_not_found_error(self) -> None: @@ -278,8 +310,8 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_filling_by_id', new=get_detail), self.assertRaises(ServiceException) as raised, ): - await FillingService.filling_detail_services(self.db, 31, make_user(1)) - get_detail.assert_awaited_once_with(self.db, 31, warehouse_ids=None) + await FillingService.filling_detail_services(self.db, 31, make_user(1), TestBatchScope.formal()) + get_detail.assert_awaited_once_with(self.db, 31, TestBatchScope.formal(), warehouse_ids=None) self.assertEqual('装填记录不存在', raised.exception.message) async def test_strain_options_return_only_enabled_business_options(self) -> None: @@ -290,11 +322,12 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): ] list_strain = AsyncMock(return_value=strains) with patch.object(MasterDao, 'list_strain', new=list_strain): - result = await FillingService.get_strain_options_services(self.db, self.user) + result = await FillingService.get_strain_options_services(self.db, self.user, TestBatchScope.formal()) query = list_strain.await_args.args[1] self.assertEqual('0', query.status) - self.assertFalse(list_strain.await_args.args[2]) + self.assertEqual(TestBatchScope.formal(), list_strain.await_args.args[2]) + self.assertFalse(list_strain.await_args.args[3]) self.assertEqual([1, 3], [option.id for option in result]) self.assertTrue(all(isinstance(option, BusinessOptionModel) for option in result)) @@ -311,7 +344,9 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock), patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock), ): - result = await FillingService.scan_filling_pallet_services(self.db, 'qr-2', self.user) + result = await FillingService.scan_filling_pallet_services( + self.db, 'qr-2', self.user, TestBatchScope.formal() + ) access.assert_awaited_once_with(self.db, self.user, 7) inventory_lock.assert_not_awaited() @@ -334,7 +369,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(AccessService, 'assert_warehouse_access', new=access), self.assertRaises(PermissionException) as raised, ): - await FillingService.scan_filling_pallet_services(self.db, 'qr-2', self.user) + await FillingService.scan_filling_pallet_services(self.db, 'qr-2', self.user, TestBatchScope.formal()) access.assert_awaited_once_with(self.db, self.user, 7) self.assertEqual('', raised.exception.data) self.assertEqual('无权访问该托盘', raised.exception.message) @@ -346,7 +381,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), self.assertRaises(exception_type), ): - await FillingService.scan_filling_pallet_services(self.db, 'missing', user) + await FillingService.scan_filling_pallet_services(self.db, 'missing', user, TestBatchScope.formal()) async def test_scan_admin_distinguishes_missing_inventory(self) -> None: with ( @@ -354,7 +389,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException) as raised, ): - await FillingService.scan_filling_pallet_services(self.db, 'qr-2', make_user(1)) + await FillingService.scan_filling_pallet_services(self.db, 'qr-2', make_user(1), TestBatchScope.formal()) self.assertEqual('托盘库存不存在', raised.exception.message) @@ -441,7 +476,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch('module_business.service.filling_service.datetime') as mocked_datetime, ): mocked_datetime.now.return_value = self.fixed_time - result = await FillingService.fill_pallet_services(self.db, self.page, self.user) + result = await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual( [ @@ -460,8 +495,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): [False, False, True], [call.kwargs.get('for_update', False) for call in access.await_args_list] ) self.assertEqual([7, 7, 7], [call.args[2] for call in access.await_args_list]) - warehouse_read.assert_awaited_once_with(self.db, 7, for_update=True) - strain_read.assert_awaited_once_with(self.db, 11, for_update=True) + warehouse_read.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) + strain_read.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), for_update=True) filling = captured['filling'] self.assertTrue(filling.filling_no.startswith('FL-')) self.assertEqual(2, filling.pallet_id) @@ -539,7 +574,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(ServiceException), ): - await FillingService.fill_pallet_services(self.db, page or self.page, self.user) + await FillingService.fill_pallet_services(self.db, page or self.page, self.user, TestBatchScope.formal()) add_filling.assert_not_awaited() add_event.assert_not_awaited() self.db.commit.assert_not_awaited() @@ -580,11 +615,11 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(ServiceException) as raised, ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('仓库已停用', raised.exception.message) self.assertEqual(['inventory-lock', 'pallet-lock', 'warehouse-read'], actions) - warehouse_read.assert_awaited_once_with(self.db, 7, for_update=True) + warehouse_read.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) strain_read.assert_not_awaited() add_filling.assert_not_awaited() add_event.assert_not_awaited() @@ -639,7 +674,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(PermissionException) as raised, ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('', raised.exception.data) self.assertEqual('无权访问该托盘', raised.exception.message) @@ -651,7 +686,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): [False, False, True], [call.kwargs.get('for_update', False) for call in access.await_args_list] ) self.assertEqual([7, 7, 7], [call.args[2] for call in access.await_args_list]) - warehouse_read.assert_awaited_once_with(self.db, 7, for_update=True) + warehouse_read.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) strain_read.assert_not_awaited() add_filling.assert_not_awaited() add_event.assert_not_awaited() @@ -676,7 +711,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'add_filling_record', new=add_filling), self.assertRaises(ServiceException), ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) add_filling.assert_not_awaited() self.db.rollback.assert_awaited_once() @@ -717,9 +752,14 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_warehouse', new=warehouse_read), patch.object(MasterDao, 'get_strain', new=strain_read), ): - result = await FillingService.fill_pallet_services(self.db, self.page, self.user) + result = await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) - get_key.assert_awaited_once_with(self.db, 'fill-key', warehouse_ids=[7]) + get_key.assert_awaited_once_with( + self.db, + 'fill-key', + TestBatchScope.formal(), + warehouse_ids=[7], + ) access.assert_awaited_once_with(self.db, self.user, 7) inventory_lock.assert_not_awaited() warehouse_read.assert_not_awaited() @@ -741,9 +781,16 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(OperationDao, 'get_filling_by_idempotency_key', new=get_key), ): - result = await FillingService.fill_pallet_services(self.db, self.page, user) + result = await FillingService.fill_pallet_services( + self.db, self.page, user, TestBatchScope.formal() + ) - get_key.assert_awaited_once_with(self.db, 'fill-key', warehouse_ids=[7]) + get_key.assert_awaited_once_with( + self.db, + 'fill-key', + TestBatchScope.formal(), + warehouse_ids=[7], + ) self.assertEqual(31, result.result) if user.user and user.user.admin: scope_read.assert_not_awaited() @@ -756,7 +803,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=existing)), self.assertRaises(ServiceException) as raised, ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertIn('幂等键', raised.exception.message) self.db.rollback.assert_awaited_once() @@ -767,7 +814,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_filling_by_idempotency_key', new=get_key), self.assertRaises(PermissionException) as raised, ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('', raised.exception.data) self.assertEqual('无权访问该仓库', raised.exception.message) get_key.assert_not_awaited() @@ -802,7 +849,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(PermissionException) as raised, ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual([7, foreign_warehouse_id], [call.args[2] for call in access.await_args_list]) self.assertEqual('', raised.exception.data) @@ -856,7 +903,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'add_filling_record', new=add_filling), patch.object(InventoryDao, 'add_event', new=add_event), ): - result = await FillingService.fill_pallet_services(self.db, self.page, self.user) + result = await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(['inventory-lock', 'pallet-lock', 'filling-lock'], actions) self.assertEqual(2, get_key.await_count) @@ -893,7 +940,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'add_filling_record', new=add_filling), self.assertRaises(ServiceException) as raised, ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('装填失败,幂等键已被其他请求使用', raised.exception.message) self.assertEqual(2, get_key.await_count) @@ -914,7 +961,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=RuntimeError('flush failed'))), self.assertRaises(RuntimeError), ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.db.rollback.assert_awaited_once() self.db.commit.assert_not_awaited() @@ -923,7 +970,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(FillingService, '_build_request_hash', side_effect=RuntimeError('hash failed')), self.assertRaises(RuntimeError), ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.db.rollback.assert_awaited_once() self.db.commit.assert_not_awaited() @@ -942,7 +989,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=self.strain)), patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=integrity_error())), ): - result = await FillingService.fill_pallet_services(self.db, self.page, self.user) + result = await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(2, key_reads.await_count) self.assertEqual( @@ -968,7 +1015,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=integrity_error())), self.assertRaises(PermissionException) as raised, ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该仓库', raised.exception.message) self.assertEqual(2, warehouse_scope.await_count) @@ -993,7 +1040,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=integrity_error())), self.assertRaises(ServiceException) as raised, ): - await FillingService.fill_pallet_services(self.db, self.page, self.user) + await FillingService.fill_pallet_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('装填失败,数据冲突', raised.exception.message) self.assertEqual(2, self.db.rollback.await_count) self.db.commit.assert_not_awaited() @@ -1009,7 +1056,13 @@ class TestFillingOperationDaoScope(unittest.IsolatedAsyncioTestCase): paginate = AsyncMock(return_value=[]) db = MagicMock(spec=AsyncSession) with patch.object(OperationDao, '_paginate', new=paginate): - await OperationDao.get_filling_list(db, query, False, warehouse_ids=[9, 7, 7]) + await OperationDao.get_filling_list( + db, + query, + warehouse_ids=[9, 7, 7], + scope=TestBatchScope.formal(), + is_page=False, + ) sql = self._compiled(paginate.await_args.args[1]) self.assertIn('biz_filling_record.warehouse_id in (7, 9)', sql) @@ -1018,7 +1071,13 @@ class TestFillingOperationDaoScope(unittest.IsolatedAsyncioTestCase): paginate = AsyncMock(return_value=[]) db = MagicMock(spec=AsyncSession) with patch.object(OperationDao, '_paginate', new=paginate): - await OperationDao.get_filling_list(db, query, False, warehouse_ids=[]) + await OperationDao.get_filling_list( + db, + query, + warehouse_ids=[], + scope=TestBatchScope.formal(), + is_page=False, + ) sql = self._compiled(paginate.await_args.args[1]) self.assertTrue('false' in sql or '0 = 1' in sql, sql) @@ -1032,7 +1091,7 @@ class TestFillingOperationDaoScope(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = None db = MagicMock(spec=AsyncSession) db.execute.return_value = result - await method(db, key, warehouse_ids=[7]) + await method(db, key, TestBatchScope.formal(), warehouse_ids=[7]) sql = self._compiled(db.execute.await_args.args[0]) self.assertIn('biz_filling_record.warehouse_id in (7)', sql) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py b/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py index 283168a..5a0c79c 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py @@ -22,7 +22,7 @@ from common.vo import ( PageResponseModel, ResponseBaseModel, ) -from exceptions.exception import PermissionException, ServiceException +from exceptions.exception import PermissionException, ServiceException, ServiceWarning from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel from module_admin.service.log_service import LogQueueService from module_admin.service.login_service import LoginService @@ -33,6 +33,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizInboundItem, @@ -129,6 +130,20 @@ def make_inbound( class TestInboundPublicContract(unittest.TestCase): + def test_public_business_methods_require_explicit_test_scope(self) -> None: + for method_name in ( + 'get_inbound_list_services', + 'inbound_detail_services', + 'get_warehouse_options_services', + 'scan_inbound_pallet_services', + 'add_inbound_services', + 'edit_inbound_services', + 'delete_inbound_services', + 'confirm_inbound_services', + ): + with self.subTest(method=method_name): + self.assertIn('test_scope', inspect.signature(getattr(InboundService, method_name)).parameters) + def test_service_and_controller_are_available(self) -> None: self.assertIsNotNone(InboundService) self.assertIsNotNone(inbound_controller_module.inbound_controller) @@ -136,7 +151,7 @@ class TestInboundPublicContract(unittest.TestCase): def test_confirm_signature_and_types_are_exact(self) -> None: signature = inspect.signature(InboundService.confirm_inbound_services) self.assertEqual( - ('query_db', 'inbound_id', 'page_object', 'current_user'), + ('query_db', 'inbound_id', 'page_object', 'current_user', 'test_scope'), tuple(signature.parameters), ) hints = get_type_hints(InboundService.confirm_inbound_services) @@ -228,7 +243,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(inbound_service_module, 'datetime') as service_datetime, ): service_datetime.now.return_value = self.fixed_time - result = await InboundService.add_inbound_services(self.db, page, self.user) + result = await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual( [ @@ -242,8 +257,8 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): ], actions, ) - inventory_lock.assert_awaited_once_with(self.db, [2, 5]) - pallet_lock.assert_awaited_once_with(self.db, [2, 5]) + inventory_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) + pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) token_hex.assert_called_once_with(16) order = captured['order'] self.assertEqual('IN-fixed-random', order.inbound_no) @@ -288,7 +303,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(ServiceException), ): - await InboundService.add_inbound_services(self.db, page, self.user) + await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) add_order.assert_not_awaited() replace_items.assert_not_awaited() @@ -313,7 +328,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventories_for_update', new=lock), self.assertRaises(exception_type), ): - await InboundService.add_inbound_services(self.db, page, self.user) + await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) lock.assert_not_awaited() self.db.rollback.assert_awaited_once() @@ -333,7 +348,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventories_for_update', new=lock), self.assertRaises(PermissionException), ): - await InboundService.add_inbound_services(self.db, page, self.user) + await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) lock.assert_not_awaited() self.db.rollback.assert_awaited_once() @@ -351,7 +366,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventories_for_update', new=pallet_lock), self.assertRaises(Exception) as raised, ): - await InboundService.add_inbound_services(self.db, page, self.user) + await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertIsInstance(raised.exception, PermissionException) self.assertEqual('无权访问该仓库', raised.exception.message) @@ -385,11 +400,11 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'replace_inbound_items', new=replace_items), self.assertRaises(PermissionException) as raised, ): - await InboundService.add_inbound_services(self.db, page, self.user) + await InboundService.add_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该仓库', raised.exception.message) self.assertEqual(2, warehouse_read.await_count) - warehouse_read.assert_awaited_with(self.db, 7, for_update=True) + warehouse_read.assert_awaited_with(self.db, 7, TestBatchScope.formal(), for_update=True) self.assertEqual(2, warehouse_mapping.await_count) warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True) add_order.assert_not_awaited() @@ -427,12 +442,13 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(inbound_service_module, 'datetime') as service_datetime, ): service_datetime.now.return_value = self.fixed_time - result = await InboundService.edit_inbound_services(self.db, page, self.user) + result = await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.default_scope.assert_awaited_once_with(self.db, 7) lock_order.assert_awaited_once_with( self.db, 11, + TestBatchScope.formal(), warehouse_ids=[2, 5, 7, 8, 11], ) self.assertEqual( @@ -443,8 +459,8 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): ], access.await_args_list, ) - inventory_lock.assert_awaited_once_with(self.db, [2, 5]) - pallet_lock.assert_awaited_once_with(self.db, [2, 5]) + inventory_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) + pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) self.assertEqual(8, inbound.warehouse_id) self.assertEqual('user-7', inbound.update_by) self.assertEqual(self.fixed_time, inbound.update_time) @@ -478,7 +494,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): MasterDao, 'get_warehouse', new=AsyncMock( - side_effect=lambda _db, warehouse_id, **_kwargs: SimpleNamespace( + side_effect=lambda _db, warehouse_id, _scope, **_kwargs: SimpleNamespace( warehouse_id=warehouse_id, status='0', ) @@ -493,7 +509,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'replace_inbound_items', new=replace), self.assertRaises(PermissionException) as raised, ): - await InboundService.edit_inbound_services(self.db, page, self.user) + await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该入库单', raised.exception.message) warehouse_mapping.assert_any_await(self.db, self.user, 7, for_update=True) @@ -519,7 +535,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'update_inbound', new=update), self.assertRaises(ServiceException), ): - await InboundService.edit_inbound_services(self.db, page, self.user) + await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal()) lock_pallets.assert_not_awaited() update.assert_not_awaited() @@ -537,12 +553,12 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), self.assertRaises(Exception) as raised, ): - await InboundService.edit_inbound_services(self.db, page, self.user) + await InboundService.edit_inbound_services(self.db, page, self.user, TestBatchScope.formal()) self.assertIsInstance(raised.exception, PermissionException) self.assertEqual('无权访问该入库单', raised.exception.message) scope.assert_awaited_once_with(self.db, 7) - lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=[7]) + lock_order.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=[7]) get_warehouse.assert_not_awaited() inventory_lock.assert_not_awaited() self.db.rollback.assert_awaited_once() @@ -556,130 +572,54 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), self.assertRaises(ServiceException) as raised, ): - await InboundService.edit_inbound_services(self.db, page, make_user(1)) + await InboundService.edit_inbound_services(self.db, page, make_user(1), TestBatchScope.formal()) self.assertEqual('入库单不存在', raised.exception.message) scope.assert_not_awaited() - lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=None) + lock_order.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=None) self.db.rollback.assert_awaited_once() async def test_batch_delete_locks_and_preflights_all_before_ordered_delete(self) -> None: - actions: list[str] = [] - - async def lock( - _db: AsyncSession, - inbound_id: int, - warehouse_ids: list[int] | None = None, - ) -> BizInboundOrder: - self.assertEqual([2, 5, 7, 8, 11], warehouse_ids) - actions.append(f'lock-{inbound_id}') - return make_inbound(inbound_id=inbound_id, warehouse_id=inbound_id) - - async def delete(_db: AsyncSession, inbound_id: int) -> None: - actions.append(f'delete-{inbound_id}') - - async def commit() -> None: - actions.append('commit') - - self.db.commit.side_effect = commit - access = AsyncMock() + lock = AsyncMock() + delete = AsyncMock() with ( - patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(side_effect=lock)), - patch.object( - MasterDao, - 'get_warehouse', - new=AsyncMock( - side_effect=lambda _db, warehouse_id, **_kwargs: SimpleNamespace( - warehouse_id=warehouse_id, - status='0', - ) - ), - ), - patch.object(AccessService, 'assert_warehouse_access', new=access), - patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=delete)), + patch.object(OperationDao, 'get_inbound_for_update', new=lock), + patch.object(OperationDao, 'delete_inbound', new=delete), + self.assertRaises(ServiceWarning), ): - result = await InboundService.delete_inbound_services(self.db, [5, 2], self.user) + await InboundService.delete_inbound_services(self.db, [5, 2], self.user, TestBatchScope.formal()) - self.assertEqual( - ['lock-2', 'lock-5', 'delete-2', 'delete-5', 'commit'], - actions, - ) - self.default_scope.assert_awaited_once_with(self.db, 7) - self.assertEqual([2, 5], [call.args[2] for call in access.await_args_list]) - self.assertTrue(all(call.kwargs.get('for_update') for call in access.await_args_list)) - self.assertTrue(result.is_success) - self.db.rollback.assert_not_awaited() + lock.assert_not_awaited() + delete.assert_not_awaited() + self.db.commit.assert_not_awaited() async def test_batch_delete_rechecks_all_current_warehouses_before_first_delete(self) -> None: - revoked_warehouse_id = 8 - documents = { - 11: make_inbound(inbound_id=11, warehouse_id=revoked_warehouse_id), - 12: make_inbound(inbound_id=12, warehouse_id=7), - } - - async def warehouse_access( - _db: AsyncSession, - _user: CurrentUserModel, - warehouse_id: int, - **kwargs: Any, - ) -> None: - if warehouse_id == revoked_warehouse_id and kwargs.get('for_update'): - raise PermissionException(data='', message='无权访问该仓库') - + lock = AsyncMock() delete = AsyncMock() - warehouse_mapping = AsyncMock(side_effect=warehouse_access) with ( - patch.object( - OperationDao, - 'get_inbound_for_update', - new=AsyncMock(side_effect=lambda _db, inbound_id, **_kwargs: documents[inbound_id]), - ), - patch.object( - MasterDao, - 'get_warehouse', - new=AsyncMock( - side_effect=lambda _db, warehouse_id, **_kwargs: SimpleNamespace( - warehouse_id=warehouse_id, - status='0', - ) - ), - ), - patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), + patch.object(OperationDao, 'get_inbound_for_update', new=lock), patch.object(OperationDao, 'delete_inbound', new=delete), - self.assertRaises(PermissionException) as raised, + self.assertRaises(ServiceWarning), ): - await InboundService.delete_inbound_services(self.db, [12, 11], self.user) + await InboundService.delete_inbound_services(self.db, [12, 11], self.user, TestBatchScope.formal()) - self.assertEqual('无权访问该入库单', raised.exception.message) - self.assertEqual([7, 8], [call.args[2] for call in warehouse_mapping.await_args_list]) - self.assertTrue(all(call.kwargs.get('for_update') for call in warehouse_mapping.await_args_list)) + lock.assert_not_awaited() delete.assert_not_awaited() self.db.commit.assert_not_awaited() - self.db.rollback.assert_awaited_once() async def test_batch_delete_is_atomic_when_later_document_is_confirmed(self) -> None: + lock = AsyncMock() delete = AsyncMock() - records = { - 2: make_inbound(inbound_id=2, warehouse_id=2), - 5: make_inbound(inbound_id=5, warehouse_id=5, status='CONFIRMED'), - } with ( - patch.object( - OperationDao, - 'get_inbound_for_update', - new=AsyncMock( - side_effect=lambda _db, inbound_id, warehouse_ids=None: records[inbound_id], - ), - ), - patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), + patch.object(OperationDao, 'get_inbound_for_update', new=lock), patch.object(OperationDao, 'delete_inbound', new=delete), - self.assertRaises(ServiceException), + self.assertRaises(ServiceWarning), ): - await InboundService.delete_inbound_services(self.db, [5, 2], self.user) + await InboundService.delete_inbound_services(self.db, [5, 2], self.user, TestBatchScope.formal()) + lock.assert_not_awaited() delete.assert_not_awaited() self.db.commit.assert_not_awaited() - self.db.rollback.assert_awaited_once() async def test_delete_scopes_parent_lock_and_hides_unmatched_document(self) -> None: lock_order = AsyncMock(return_value=None) @@ -688,16 +628,13 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope, patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), patch.object(OperationDao, 'delete_inbound', new=delete), - self.assertRaises(Exception) as raised, + self.assertRaises(ServiceWarning), ): - await InboundService.delete_inbound_services(self.db, [404], self.user) + await InboundService.delete_inbound_services(self.db, [404], self.user, TestBatchScope.formal()) - self.assertIsInstance(raised.exception, PermissionException) - self.assertEqual('无权访问该入库单', raised.exception.message) - scope.assert_awaited_once_with(self.db, 7) - lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=[7]) + scope.assert_not_awaited() + lock_order.assert_not_awaited() delete.assert_not_awaited() - self.db.rollback.assert_awaited_once() async def test_admin_missing_delete_keeps_precise_not_found(self) -> None: scope = AsyncMock() @@ -705,14 +642,12 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), - self.assertRaises(ServiceException) as raised, + self.assertRaises(ServiceWarning), ): - await InboundService.delete_inbound_services(self.db, [404], make_user(1)) + await InboundService.delete_inbound_services(self.db, [404], make_user(1), TestBatchScope.formal()) - self.assertEqual('入库单不存在', raised.exception.message) scope.assert_not_awaited() - lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=None) - self.db.rollback.assert_awaited_once() + lock_order.assert_not_awaited() async def test_all_draft_mutations_convert_integrity_errors(self) -> None: add_page = InboundCreateModel(warehouseId=7, palletIds=[2]) @@ -732,7 +667,7 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'add_inbound', new=AsyncMock(side_effect=integrity_error())), self.assertRaises(ServiceException), ): - await InboundService.add_inbound_services(self.db, add_page, self.user) + await InboundService.add_inbound_services(self.db, add_page, self.user, TestBatchScope.formal()) self.db.rollback.assert_awaited_once() self.db.reset_mock() @@ -749,10 +684,9 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase): ), patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=integrity_error())), - self.assertRaises(ServiceException), + self.assertRaises(ServiceWarning), ): - await InboundService.delete_inbound_services(self.db, [11], self.user) - self.db.rollback.assert_awaited_once() + await InboundService.delete_inbound_services(self.db, [11], self.user, TestBatchScope.formal()) class TestInboundReadService(unittest.IsolatedAsyncioTestCase): @@ -774,10 +708,18 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 3, 7])) as scope, patch.object(OperationDao, 'get_inbound_list', new=get_list), ): - actual = await InboundService.get_inbound_list_services(self.db, query, self.user, is_page=True) + actual = await InboundService.get_inbound_list_services( + self.db, query, self.user, TestBatchScope.formal(), is_page=True + ) scope.assert_awaited_once_with(self.db, 7) - get_list.assert_awaited_once_with(self.db, query, True, warehouse_ids=[3, 7]) + get_list.assert_awaited_once_with( + self.db, + query, + warehouse_ids=[3, 7], + scope=TestBatchScope.formal(), + is_page=True, + ) self.assertIsInstance(actual, PageModel) self.assertIsInstance(actual.rows[0], InboundModel) @@ -790,10 +732,18 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), patch.object(OperationDao, 'get_inbound_list', new=get_list), ): - await InboundService.get_inbound_list_services(self.db, query, make_user(1), is_page=True) + await InboundService.get_inbound_list_services( + self.db, query, make_user(1), TestBatchScope.formal(), is_page=True + ) scope.assert_not_awaited() - get_list.assert_awaited_once_with(self.db, query, True, warehouse_ids=None) + get_list.assert_awaited_once_with( + self.db, + query, + warehouse_ids=None, + scope=TestBatchScope.formal(), + is_page=True, + ) async def test_explicit_unauthorized_warehouse_is_403_before_query(self) -> None: query = InboundPageQueryModel(warehouseId=9) @@ -803,7 +753,9 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_inbound_list', new=get_list), self.assertRaises(PermissionException), ): - await InboundService.get_inbound_list_services(self.db, query, self.user, is_page=True) + await InboundService.get_inbound_list_services( + self.db, query, self.user, TestBatchScope.formal(), is_page=True + ) get_list.assert_not_awaited() async def test_user_without_scope_gets_empty_paginated_result_without_total_leak(self) -> None: @@ -814,9 +766,17 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])), patch.object(OperationDao, 'get_inbound_list', new=get_list), ): - actual = await InboundService.get_inbound_list_services(self.db, query, self.user, is_page=True) + actual = await InboundService.get_inbound_list_services( + self.db, query, self.user, TestBatchScope.formal(), is_page=True + ) - get_list.assert_awaited_once_with(self.db, query, True, warehouse_ids=[]) + get_list.assert_awaited_once_with( + self.db, + query, + warehouse_ids=[], + scope=TestBatchScope.formal(), + is_page=True, + ) self.assertEqual([], actual.rows) self.assertEqual(0, actual.total) self.assertFalse(actual.has_next) @@ -834,10 +794,10 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), patch.object(AccessService, 'assert_warehouse_access', new=access), ): - actual = await InboundService.inbound_detail_services(self.db, 11, self.user) + actual = await InboundService.inbound_detail_services(self.db, 11, self.user, TestBatchScope.formal()) scope.assert_awaited_once_with(self.db, 7) - get_detail.assert_awaited_once_with(self.db, 11, warehouse_ids=[3, 7]) + get_detail.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[3, 7]) access.assert_not_awaited() self.assertIsInstance(actual, InboundModel) @@ -848,11 +808,11 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), self.assertRaises(Exception) as raised, ): - await InboundService.inbound_detail_services(self.db, 404, self.user) + await InboundService.inbound_detail_services(self.db, 404, self.user, TestBatchScope.formal()) self.assertIsInstance(raised.exception, PermissionException) self.assertEqual('无权访问该入库单', raised.exception.message) - get_detail.assert_awaited_once_with(self.db, 404, warehouse_ids=[7]) + get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=[7]) async def test_admin_missing_detail_keeps_precise_not_found(self) -> None: scope = AsyncMock() @@ -862,11 +822,11 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), self.assertRaises(ServiceException) as raised, ): - await InboundService.inbound_detail_services(self.db, 404, make_user(1)) + await InboundService.inbound_detail_services(self.db, 404, make_user(1), TestBatchScope.formal()) self.assertEqual('入库单不存在', raised.exception.message) scope.assert_not_awaited() - get_detail.assert_awaited_once_with(self.db, 404, warehouse_ids=None) + get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=None) async def test_warehouse_options_filter_disabled_and_unauthorized_records(self) -> None: warehouses = [ @@ -878,11 +838,12 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)) as list_warehouse, patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[2, 3])), ): - actual = await InboundService.get_warehouse_options_services(self.db, self.user) + actual = await InboundService.get_warehouse_options_services(self.db, self.user, TestBatchScope.formal()) query = list_warehouse.await_args.args[1] self.assertEqual('0', query.status) - self.assertIs(False, list_warehouse.await_args.args[2]) + self.assertEqual(TestBatchScope.formal(), list_warehouse.await_args.args[2]) + self.assertIs(False, list_warehouse.await_args.args[3]) self.assertEqual([BusinessOptionModel(id=2, code='W2', name='二仓')], actual) self.assertEqual({'id', 'code', 'name'}, set(BusinessOptionModel.model_fields)) @@ -896,7 +857,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)), patch.object(AccessDao, 'list_user_warehouse_ids', new=scope), ): - actual = await InboundService.get_warehouse_options_services(self.db, make_user(1)) + actual = await InboundService.get_warehouse_options_services(self.db, make_user(1), TestBatchScope.formal()) scope.assert_not_awaited() self.assertEqual([2, 5], [option.id for option in actual]) @@ -914,7 +875,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)), patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[2])), ): - actual = await InboundService.get_warehouse_options_services(self.db, self.user) + actual = await InboundService.get_warehouse_options_services(self.db, self.user, TestBatchScope.formal()) self.assertEqual([BusinessOptionModel(id=2, code='W2', name='二仓')], actual) @@ -945,11 +906,12 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase): 'persisted-token', self.query, self.user, + TestBatchScope.formal(), ) access.assert_awaited_once_with(self.db, self.user, 7) - get_pallet.assert_awaited_once_with(self.db, 'persisted-token') - get_inventory.assert_awaited_once_with(self.db, 2) + get_pallet.assert_awaited_once_with(self.db, 'persisted-token', TestBatchScope.formal()) + get_inventory.assert_awaited_once_with(self.db, 2, TestBatchScope.formal()) locking_read.assert_not_awaited() add_event.assert_not_awaited() self.db.commit.assert_not_awaited() @@ -987,6 +949,7 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase): 'persisted-token', self.query, self.user, + TestBatchScope.formal(), ) async def test_scan_rejects_unauthorized_disabled_or_missing_target_warehouse_first(self) -> None: @@ -1001,7 +964,9 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallet_by_qr_token', new=get_pallet), self.assertRaises(PermissionException), ): - await InboundService.scan_inbound_pallet_services(self.db, 'token', self.query, self.user) + await InboundService.scan_inbound_pallet_services( + self.db, 'token', self.query, self.user, TestBatchScope.formal() + ) get_pallet.assert_not_awaited() for warehouse in (None, SimpleNamespace(warehouse_id=7, status='1')): @@ -1013,7 +978,9 @@ class TestInboundScanService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallet_by_qr_token', new=get_pallet), self.assertRaises(ServiceException), ): - await InboundService.scan_inbound_pallet_services(self.db, 'token', self.query, self.user) + await InboundService.scan_inbound_pallet_services( + self.db, 'token', self.query, self.user, TestBatchScope.formal() + ) get_pallet.assert_not_awaited() @@ -1045,12 +1012,14 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), self.assertRaises(Exception) as raised, ): - await InboundService.confirm_inbound_services(self.db, 404, self.command, self.user) + await InboundService.confirm_inbound_services( + self.db, 404, self.command, self.user, TestBatchScope.formal() + ) self.assertIsInstance(raised.exception, PermissionException) self.assertEqual('无权访问该入库单', raised.exception.message) scope.assert_awaited_once_with(self.db, 7) - get_detail.assert_awaited_once_with(self.db, 404, warehouse_ids=[7]) + get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=[7]) get_by_key.assert_not_awaited() lock_order.assert_not_awaited() inventory_lock.assert_not_awaited() @@ -1068,11 +1037,13 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_inbound_for_update', new=lock_order), self.assertRaises(ServiceException) as raised, ): - await InboundService.confirm_inbound_services(self.db, 404, self.command, make_user(1)) + await InboundService.confirm_inbound_services( + self.db, 404, self.command, make_user(1), TestBatchScope.formal() + ) self.assertEqual('入库单不存在', raised.exception.message) scope.assert_not_awaited() - get_detail.assert_awaited_once_with(self.db, 404, warehouse_ids=None) + get_detail.assert_awaited_once_with(self.db, 404, TestBatchScope.formal(), warehouse_ids=None) get_by_key.assert_not_awaited() lock_order.assert_not_awaited() self.db.rollback.assert_awaited_once() @@ -1169,7 +1140,9 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patches[10] as warehouse_lock, ): service_datetime.now.return_value = self.fixed_time - result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + result = await InboundService.confirm_inbound_services( + self.db, 11, self.command, self.user, TestBatchScope.formal() + ) return { 'result': result, @@ -1209,17 +1182,33 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): captured['actions'], ) self.default_scope.assert_awaited_once_with(self.db, 7) - captured['detail_read'].assert_awaited_once_with(self.db, 11, warehouse_ids=[7]) + captured['detail_read'].assert_awaited_once_with( + self.db, + 11, + TestBatchScope.formal(), + warehouse_ids=[7], + ) captured['key_read'].assert_awaited_once_with( self.db, 'confirm-key', + TestBatchScope.formal(), warehouse_ids=[7], ) - captured['order_lock'].assert_awaited_once_with(self.db, 11, warehouse_ids=[7]) - captured['warehouse_lock'].assert_awaited_once_with(self.db, 7, for_update=True) + captured['order_lock'].assert_awaited_once_with( + self.db, + 11, + TestBatchScope.formal(), + warehouse_ids=[7], + ) + captured['warehouse_lock'].assert_awaited_once_with( + self.db, + 7, + TestBatchScope.formal(), + for_update=True, + ) captured['access_probe'].assert_awaited_once_with(self.db, self.user, 7, for_update=True) - captured['inventory_lock'].assert_awaited_once_with(self.db, [2, 5]) - captured['pallet_lock'].assert_awaited_once_with(self.db, [2, 5]) + captured['inventory_lock'].assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) + captured['pallet_lock'].assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) captured['ensure_transition'].assert_has_calls( [ unittest.mock.call(PalletFlowState.UNSTOCKED, PalletFlowState.EMPTY_IN_WAREHOUSE), @@ -1299,10 +1288,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(PermissionException) as raised, ): - await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该入库单', raised.exception.message) - warehouse_lock.assert_awaited_once_with(self.db, 7, for_update=True) + warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) warehouse_mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True) self.assertEqual('DRAFT', inbound.status) self.assertEqual('UNSTOCKED', inventory.flow_state) @@ -1328,9 +1317,9 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(ServiceException), ): - await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) - pallet_lock.assert_awaited_once_with(self.db, [2, 5]) + pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) self.assertEqual('DRAFT', inbound.status) self.assertEqual('UNSTOCKED', inventories[0].flow_state) update.assert_not_awaited() @@ -1360,7 +1349,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaisesRegex(ValueError, 'invalid'), ): - await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual('DRAFT', inbound.status) self.assertEqual('UNSTOCKED', inventory.flow_state) @@ -1391,10 +1380,12 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')), ) as warehouse_lock, ): - result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + result = await InboundService.confirm_inbound_services( + self.db, 11, self.command, self.user, TestBatchScope.formal() + ) - lock.assert_awaited_once_with(self.db, 11, warehouse_ids=[7]) - warehouse_lock.assert_awaited_once_with(self.db, 7, for_update=True) + lock.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7]) + warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) self.assertEqual( [ unittest.mock.call(self.db, self.user, 7), @@ -1424,6 +1415,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): async def read_key( _db: AsyncSession, _key: str, + _scope: TestBatchScope, *, warehouse_ids: list[int] | None = None, ) -> BizInboundOrder | None: @@ -1455,13 +1447,15 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): 11, self.command, self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) - get_detail.assert_awaited_once_with(self.db, 11, warehouse_ids=[7, 8]) + get_detail.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7, 8]) key_read.assert_awaited_once_with( self.db, 'confirm-key', + TestBatchScope.formal(), warehouse_ids=[7], ) self.db.rollback.assert_awaited_once() @@ -1496,10 +1490,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), self.assertRaises(PermissionException) as raised, ): - await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该入库单', raised.exception.message) - warehouse_lock.assert_awaited_once_with(self.db, 7, for_update=True) + warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True) inventory_lock.assert_not_awaited() self.db.commit.assert_not_awaited() @@ -1526,7 +1520,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_inbound_for_update', new=lock), self.assertRaises(ServiceException), ): - await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) access.assert_awaited_once_with(self.db, self.user, 8) lock.assert_not_awaited() @@ -1548,7 +1542,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock), self.assertRaises(ServiceException), ): - await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) inventory_lock.assert_not_awaited() self.db.rollback.assert_awaited_once() @@ -1574,10 +1568,12 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'update_inbound', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()), ): - result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + result = await InboundService.confirm_inbound_services( + self.db, 11, self.command, self.user, TestBatchScope.formal() + ) - get_detail.assert_awaited_once_with(self.db, 11, warehouse_ids=[7]) - inventory_lock.assert_awaited_once_with(self.db, [5]) + get_detail.assert_awaited_once_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7]) + inventory_lock.assert_awaited_once_with(self.db, [5], TestBatchScope.formal()) self.assertEqual('EMPTY_IN_WAREHOUSE', inventory.flow_state) self.assertTrue(result.is_success) @@ -1608,7 +1604,9 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'update_inbound', new=AsyncMock()), patch.object(InventoryDao, 'add_event', new=AsyncMock()), ): - result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + result = await InboundService.confirm_inbound_services( + self.db, 11, self.command, self.user, TestBatchScope.formal() + ) self.assertEqual(2, parent_lock.await_count) self.assertEqual( @@ -1621,8 +1619,8 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): self.assertEqual(2, key_reads.await_count) key_reads.assert_has_awaits( [ - unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]), - unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]), + unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]), + unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]), ] ) self.assertEqual(2, self.db.rollback.await_count) @@ -1647,6 +1645,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): async def read_key( _db: AsyncSession, _key: str, + _scope: TestBatchScope, *, warehouse_ids: list[int] | None = None, ) -> BizInboundOrder | None: @@ -1683,6 +1682,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): 11, self.command, self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) @@ -1690,8 +1690,8 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): self.assertEqual(2, parent_lock.await_count) self.assertEqual( [ - unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]), - unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]), + unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]), + unittest.mock.call(self.db, 'confirm-key', TestBatchScope.formal(), warehouse_ids=[7]), ], key_read.await_args_list, ) @@ -1743,11 +1743,11 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=AsyncMock()), self.assertRaises(PermissionException) as raised, ): - await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该入库单', raised.exception.message) self.assertEqual(2, parent_lock.await_count) - parent_lock.assert_awaited_with(self.db, 11, warehouse_ids=[7]) + parent_lock.assert_awaited_with(self.db, 11, TestBatchScope.formal(), warehouse_ids=[7]) self.assertEqual(2, warehouse_lock.await_count) self.assertEqual(2, locked_access_count) self.assertEqual(2, self.db.rollback.await_count) @@ -1782,7 +1782,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=AsyncMock()), self.assertRaises(ServiceException), ): - await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user) + await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user, TestBatchScope.formal()) self.assertEqual(2, self.db.rollback.await_count) @@ -1836,6 +1836,7 @@ class TestInboundLogPermission(unittest.IsolatedAsyncioTestCase): add_inbound=InboundCreateModel(warehouseId=404, palletIds=[2]), query_db=db, current_user=user, + test_scope=TestBatchScope.formal(), ) finally: RequestContext.reset_current_user(user_token) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_inventory_dao.py b/shierfeng-fastapi-backend/tests/module_business/test_inventory_dao.py index cf2cdbe..a9736fa 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_inventory_dao.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_inventory_dao.py @@ -13,6 +13,7 @@ from module_business.dao import inventory_dao as inventory_dao_module from module_business.dao import operation_dao as operation_dao_module from module_business.dao.inventory_dao import InventoryDao from module_business.dao.operation_dao import OperationDao +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import ( BizFillingRecord, @@ -66,11 +67,11 @@ class TestDaoPublicContract(unittest.TestCase): def test_inventory_dao_critical_parameter_names_are_stable(self) -> None: expected = { - 'get_inventory_for_update': ('db', 'pallet_id'), - 'get_inventories_for_update': ('db', 'pallet_ids'), - 'get_pallet_by_qr_token': ('db', 'qr_token'), - 'get_pallets_by_ids': ('db', 'pallet_ids'), - 'get_event_by_idempotency_key': ('db', 'pallet_id', 'idempotency_key'), + 'get_inventory_for_update': ('db', 'pallet_id', 'scope'), + 'get_inventories_for_update': ('db', 'pallet_ids', 'scope'), + 'get_pallet_by_qr_token': ('db', 'qr_token', 'scope'), + 'get_pallets_by_ids': ('db', 'pallet_ids', 'scope'), + 'get_event_by_idempotency_key': ('db', 'pallet_id', 'idempotency_key', 'scope'), 'add_event': ('db', 'inventory', 'event'), } for method_name, parameter_names in expected.items(): @@ -121,51 +122,55 @@ class TestDaoPublicContract(unittest.TestCase): def test_operation_dao_critical_parameter_names_and_defaults_are_stable(self) -> None: expected = { - 'get_inbound_for_update': ('db', 'inbound_id', 'warehouse_ids'), - 'get_shipment_for_update': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'), - 'get_return_for_update': ('db', 'return_id', 'site_ids', 'warehouse_ids'), - 'get_active_filling_for_update': ('db', 'filling_id'), + 'get_inbound_for_update': ('db', 'inbound_id', 'scope', 'warehouse_ids'), + 'get_shipment_for_update': ('db', 'shipment_id', 'scope', 'warehouse_ids', 'site_ids'), + 'get_return_for_update': ('db', 'return_id', 'scope', 'site_ids', 'warehouse_ids'), + 'get_active_filling_for_update': ('db', 'filling_id', 'scope'), 'get_inbound_by_confirm_idempotency_key': ( 'db', 'idempotency_key', + 'scope', 'warehouse_ids', ), 'get_shipment_by_confirm_idempotency_key': ( 'db', 'idempotency_key', + 'scope', 'warehouse_ids', 'site_ids', ), - 'get_filling_by_idempotency_key': ('db', 'idempotency_key', 'warehouse_ids'), - 'get_usage_by_idempotency_key': ('db', 'idempotency_key', 'site_ids'), + 'get_filling_by_idempotency_key': ('db', 'idempotency_key', 'scope', 'warehouse_ids'), + 'get_usage_by_idempotency_key': ('db', 'idempotency_key', 'scope', 'site_ids'), 'get_return_by_request_idempotency_key': ( 'db', 'idempotency_key', 'source_site_id', 'target_warehouse_id', + 'scope', ), 'get_return_by_confirm_idempotency_key': ( 'db', 'idempotency_key', 'source_site_id', 'target_warehouse_id', + 'scope', ), - 'get_inbound_by_id': ('db', 'inbound_id', 'warehouse_ids'), + 'get_inbound_by_id': ('db', 'inbound_id', 'scope', 'warehouse_ids'), 'add_inbound': ('db', 'inbound'), 'update_inbound': ('db', 'inbound'), 'delete_inbound': ('db', 'inbound_id'), 'replace_inbound_items': ('db', 'inbound_id', 'items'), - 'get_filling_by_id': ('db', 'filling_id', 'warehouse_ids'), + 'get_filling_by_id': ('db', 'filling_id', 'scope', 'warehouse_ids'), 'add_filling_record': ('db', 'filling'), 'update_filling_record': ('db', 'filling'), - 'get_shipment_by_id': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'), + 'get_shipment_by_id': ('db', 'shipment_id', 'scope', 'warehouse_ids', 'site_ids'), 'add_shipment': ('db', 'shipment'), 'update_shipment': ('db', 'shipment'), 'delete_shipment': ('db', 'shipment_id'), 'replace_shipment_items': ('db', 'shipment_id', 'items'), - 'get_usage_by_id': ('db', 'usage_id', 'site_ids'), + 'get_usage_by_id': ('db', 'usage_id', 'scope', 'site_ids'), 'add_usage_record': ('db', 'usage'), - 'get_return_by_id': ('db', 'return_id', 'site_ids', 'warehouse_ids'), + 'get_return_by_id': ('db', 'return_id', 'scope', 'site_ids', 'warehouse_ids'), 'add_return_order': ('db', 'return_order'), 'update_return_order': ('db', 'return_order'), 'add_return_items': ('db', 'items'), @@ -177,11 +182,10 @@ class TestDaoPublicContract(unittest.TestCase): inbound_list_signature = inspect.signature(OperationDao.get_inbound_list) self.assertEqual( - ('db', 'query_object', 'is_page', 'warehouse_ids'), + ('db', 'query_object', 'warehouse_ids', 'scope', 'is_page'), tuple(inbound_list_signature.parameters), ) self.assertIs(True, inbound_list_signature.parameters['is_page'].default) - self.assertIsNone(inbound_list_signature.parameters['warehouse_ids'].default) for method_name in ( 'get_inbound_for_update', @@ -208,28 +212,24 @@ class TestDaoPublicContract(unittest.TestCase): filling_list_signature = inspect.signature(OperationDao.get_filling_list) self.assertEqual( - ('db', 'query_object', 'is_page', 'warehouse_ids'), + ('db', 'query_object', 'warehouse_ids', 'scope', 'is_page'), tuple(filling_list_signature.parameters), ) self.assertIs(True, filling_list_signature.parameters['is_page'].default) - self.assertIsNone(filling_list_signature.parameters['warehouse_ids'].default) shipment_list_signature = inspect.signature(OperationDao.get_shipment_list) self.assertEqual( - ('db', 'query_object', 'is_page', 'warehouse_ids', 'site_ids'), + ('db', 'query_object', 'warehouse_ids', 'site_ids', 'scope', 'is_page'), tuple(shipment_list_signature.parameters), ) self.assertIs(True, shipment_list_signature.parameters['is_page'].default) - self.assertIsNone(shipment_list_signature.parameters['warehouse_ids'].default) - self.assertIsNone(shipment_list_signature.parameters['site_ids'].default) usage_list_signature = inspect.signature(OperationDao.get_usage_list) self.assertEqual( - ('db', 'query_object', 'is_page', 'site_ids'), + ('db', 'query_object', 'site_ids', 'scope', 'is_page'), tuple(usage_list_signature.parameters), ) self.assertIs(True, usage_list_signature.parameters['is_page'].default) - self.assertIsNone(usage_list_signature.parameters['site_ids'].default) for method_name in ( 'get_usage_by_idempotency_key', @@ -243,14 +243,13 @@ class TestDaoPublicContract(unittest.TestCase): return_list_signature = inspect.signature(OperationDao.get_return_list) self.assertEqual( - ('db', 'query_object', 'is_page', 'site_ids', 'warehouse_ids'), + ('db', 'query_object', 'site_ids', 'warehouse_ids', 'scope', 'is_page'), tuple(return_list_signature.parameters), ) self.assertIs(True, return_list_signature.parameters['is_page'].default) for method_name in ( 'get_return_for_update', 'get_return_by_id', - 'get_return_list', ): with self.subTest(method=method_name): signature = inspect.signature(getattr(OperationDao, method_name)) @@ -284,7 +283,7 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = inventory self.db.execute.return_value = result - actual = await InventoryDao.get_inventory_for_update(self.db, pallet_id=7) + actual = await InventoryDao.get_inventory_for_update(self.db, pallet_id=7, scope=TestBatchScope.formal()) self.assertIs(inventory, actual) statement = self.db.execute.await_args.args[0] @@ -305,7 +304,7 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase): result.scalars.return_value.all.return_value = inventories self.db.execute.return_value = result - actual = await InventoryDao.get_inventories_for_update(self.db, [9, 2, 5, 2]) + actual = await InventoryDao.get_inventories_for_update(self.db, [9, 2, 5, 2], scope=TestBatchScope.formal()) self.assertEqual([2, 5, 9], [inventory.pallet_id for inventory in actual]) statement = self.db.execute.await_args.args[0] @@ -318,7 +317,7 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase): self.assertIn('FOR UPDATE', sql) async def test_get_inventories_for_update_empty_input_skips_sql(self) -> None: - self.assertEqual([], await InventoryDao.get_inventories_for_update(self.db, [])) + self.assertEqual([], await InventoryDao.get_inventories_for_update(self.db, [], scope=TestBatchScope.formal())) self.db.execute.assert_not_awaited() async def test_get_pallet_by_qr_token_uses_exact_token_without_lock(self) -> None: @@ -327,7 +326,9 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = pallet self.db.execute.return_value = result - actual = await InventoryDao.get_pallet_by_qr_token(self.db, qr_token='fixed-token') + actual = await InventoryDao.get_pallet_by_qr_token( + self.db, qr_token='fixed-token', scope=TestBatchScope.formal() + ) self.assertIs(pallet, actual) statement = self.db.execute.await_args.args[0] @@ -346,7 +347,7 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase): result.scalars.return_value.all.return_value = pallets self.db.execute.return_value = result - actual = await InventoryDao.get_pallets_by_ids(self.db, [9, 2, 5, 2]) + actual = await InventoryDao.get_pallets_by_ids(self.db, [9, 2, 5, 2], scope=TestBatchScope.formal()) self.assertEqual([2, 5, 9], [pallet.pallet_id for pallet in actual]) statement = self.db.execute.await_args.args[0] @@ -359,7 +360,7 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase): self.assertIn('FOR UPDATE', sql) async def test_get_pallets_by_ids_empty_input_skips_sql(self) -> None: - self.assertEqual([], await InventoryDao.get_pallets_by_ids(self.db, [])) + self.assertEqual([], await InventoryDao.get_pallets_by_ids(self.db, [], scope=TestBatchScope.formal())) self.db.execute.assert_not_awaited() async def test_get_event_by_idempotency_key_uses_pallet_scoped_key(self) -> None: @@ -372,6 +373,7 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase): self.db, pallet_id=7, idempotency_key='event-key', + scope=TestBatchScope.formal(), ) self.assertIs(event, actual) @@ -474,7 +476,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = record self.db.execute.return_value = result - actual = await method(self.db, record_id) + actual = await method(self.db, record_id, scope=TestBatchScope.formal()) self.assertIs(record, actual) statement = self.db.execute.await_args.args[0] @@ -504,6 +506,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): 12, warehouse_ids=[7, 3, 7], site_ids=[11, 9, 11], + scope=TestBatchScope.formal(), ) self.assertIs(shipment, actual) @@ -540,6 +543,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): 12, warehouse_ids=[], site_ids=[9], + scope=TestBatchScope.formal(), ) self.assertIsNone(actual) @@ -562,7 +566,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): item_result.scalars.return_value.all.return_value = items self.db.execute.side_effect = [inbound_result, item_result] - actual = await OperationDao.get_inbound_for_update(self.db, inbound_id=11) + actual = await OperationDao.get_inbound_for_update(self.db, inbound_id=11, scope=TestBatchScope.formal()) self.assertIs(inbound, actual) self.assertEqual(items, actual.items) @@ -599,6 +603,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): self.db, inbound_id=11, warehouse_ids=[7, 3, 7], + scope=TestBatchScope.formal(), ) self.assertIsNone(actual) @@ -647,7 +652,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = record self.db.execute.return_value = result - actual = await method(self.db, 'same-key', **kwargs) + actual = await method(self.db, 'same-key', scope=TestBatchScope.formal(), **kwargs) self.assertIs(record, actual) statement = self.db.execute.await_args.args[0] @@ -673,6 +678,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): self.db, 'confirm-key', warehouse_ids=[7, 3, 7], + scope=TestBatchScope.formal(), ) self.assertIsNone(actual) @@ -807,7 +813,16 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): marker = object() is_page = index % 2 == 0 with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as paginate: - actual = await method(self.db, query, is_page=is_page) + kwargs = { + 'scope': TestBatchScope.formal(), + 'is_page': is_page, + } + parameters = inspect.signature(method).parameters + if 'warehouse_ids' in parameters: + kwargs['warehouse_ids'] = None + if 'site_ids' in parameters: + kwargs['site_ids'] = None + actual = await method(self.db, query, **kwargs) self.assertIs(marker, actual) args = paginate.await_args.args @@ -829,6 +844,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): query, is_page=True, warehouse_ids=[5, 2, 5], + scope=TestBatchScope.formal(), ) self.assertIs(marker, actual) @@ -848,6 +864,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): query, is_page=True, warehouse_ids=[], + scope=TestBatchScope.formal(), ) self.assertIs(marker, actual) @@ -866,6 +883,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): query, is_page=True, warehouse_ids=None, + scope=TestBatchScope.formal(), ) sql = compile_sql(paginate.await_args.args[1]) @@ -882,6 +900,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): is_page=True, warehouse_ids=[7, 3, 7], site_ids=[11, 9, 11], + scope=TestBatchScope.formal(), ) self.assertIs(marker, actual) @@ -898,6 +917,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): query, warehouse_ids=[7], site_ids=[], + scope=TestBatchScope.formal(), ) empty_statement = empty_paginate.await_args.args[1] self.assertIn('false = 1', compile_sql(empty_statement, mysql.dialect())) @@ -914,6 +934,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): 12, warehouse_ids=[7, 3], site_ids=[11, 9], + scope=TestBatchScope.formal(), ) ) self.db.execute.assert_awaited_once() @@ -927,6 +948,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): 'confirm-key', warehouse_ids=[7, 3], site_ids=[11, 9], + scope=TestBatchScope.formal(), ) ) key_statement = self.db.execute.await_args.args[0] @@ -949,6 +971,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): query, is_page=True, site_ids=[11, 9, 11], + scope=TestBatchScope.formal(), ) self.assertIs(marker, actual) @@ -957,7 +980,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): scalar_result = MagicMock() scalar_result.scalar_one_or_none.return_value = None self.db.execute.return_value = scalar_result - await OperationDao.get_usage_by_id(self.db, 15, site_ids=[11, 9, 11]) + await OperationDao.get_usage_by_id(self.db, 15, site_ids=[11, 9, 11], scope=TestBatchScope.formal()) detail_statement = self.db.execute.await_args.args[0] self.db.reset_mock() @@ -966,6 +989,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): self.db, 'usage-key', site_ids=[11, 9, 11], + scope=TestBatchScope.formal(), ) key_statement = self.db.execute.await_args.args[0] @@ -976,17 +1000,19 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): self.assertIn('biz_usage_record.site_id IN (9, 11)', sql) with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as empty_paginate: - await OperationDao.get_usage_list(self.db, query, site_ids=[]) + await OperationDao.get_usage_list(self.db, query, site_ids=[], scope=TestBatchScope.formal()) empty_list = empty_paginate.await_args.args[1] self.db.reset_mock() self.db.execute.return_value = scalar_result - await OperationDao.get_usage_by_id(self.db, 15, site_ids=[]) + await OperationDao.get_usage_by_id(self.db, 15, site_ids=[], scope=TestBatchScope.formal()) empty_detail = self.db.execute.await_args.args[0] self.db.reset_mock() self.db.execute.return_value = scalar_result - await OperationDao.get_usage_by_idempotency_key(self.db, 'usage-key', site_ids=[]) + await OperationDao.get_usage_by_idempotency_key( + self.db, 'usage-key', site_ids=[], scope=TestBatchScope.formal() + ) empty_key = self.db.execute.await_args.args[0] for dialect in (mysql.dialect(), postgresql.dialect()): @@ -999,7 +1025,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): query = UsagePageQueryModel() marker = object() with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as paginate: - await OperationDao.get_usage_list(self.db, query, site_ids=None) + await OperationDao.get_usage_list(self.db, query, site_ids=None, scope=TestBatchScope.formal()) sql = compile_sql(paginate.await_args.args[1]) self.assertNotIn('site_id IN', sql) @@ -1023,6 +1049,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): self.db, 'usage-key', site_ids=site_ids, + scope=TestBatchScope.formal(), ) statement = self.db.execute.await_args.args[0] @@ -1081,7 +1108,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): item_result.scalars.return_value.all.return_value = items self.db.execute.side_effect = [order_result, item_result] - actual = await method(self.db, order_id) + actual = await method(self.db, order_id, scope=TestBatchScope.formal()) self.assertIs(order, actual) self.assertEqual(items, order.items) @@ -1102,7 +1129,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = None self.db.execute.return_value = result - self.assertIsNone(await OperationDao.get_inbound_by_id(self.db, inbound_id=404)) + self.assertIsNone(await OperationDao.get_inbound_by_id(self.db, inbound_id=404, scope=TestBatchScope.formal())) self.db.execute.assert_awaited_once() @@ -1119,6 +1146,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): self.db, inbound_id=11, warehouse_ids=[], + scope=TestBatchScope.formal(), ) self.assertIsNone(actual) @@ -1142,7 +1170,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = record self.db.execute.return_value = result - actual = await method(self.db, record_id) + actual = await method(self.db, record_id, scope=TestBatchScope.formal()) self.assertIs(record, actual) statement = self.db.execute.await_args.args[0] 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 7dc411e..d374617 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_return_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_return_service.py @@ -25,6 +25,7 @@ from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState, ReturnStatus +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizWarehouse from module_business.entity.do.operation_do import ( BizFillingRecord, @@ -184,20 +185,22 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase): def test_return_reads_expose_stage_scopes(self) -> None: expected = { - OperationDao.get_return_for_update: ('db', 'return_id', 'site_ids', 'warehouse_ids'), + OperationDao.get_return_for_update: ('db', 'return_id', 'scope', 'site_ids', 'warehouse_ids'), OperationDao.get_return_by_request_idempotency_key: ( 'db', 'idempotency_key', 'source_site_id', 'target_warehouse_id', + 'scope', ), OperationDao.get_return_by_confirm_idempotency_key: ( 'db', 'idempotency_key', 'source_site_id', 'target_warehouse_id', + 'scope', ), - OperationDao.get_return_by_id: ('db', 'return_id', 'site_ids', 'warehouse_ids'), + OperationDao.get_return_by_id: ('db', 'return_id', 'scope', 'site_ids', 'warehouse_ids'), } for method, parameters in expected.items(): with self.subTest(method=method.__name__): @@ -208,7 +211,7 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase): list_signature = inspect.signature(OperationDao.get_return_list) self.assertEqual( - ('db', 'query_object', 'is_page', 'site_ids', 'warehouse_ids'), + ('db', 'query_object', 'site_ids', 'warehouse_ids', 'scope', 'is_page'), tuple(list_signature.parameters), ) self.assertIs(True, list_signature.parameters['is_page'].default) @@ -235,6 +238,7 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase): 13, site_ids=[9, 7, 9], warehouse_ids=[], + scope=TestBatchScope.formal(), ) self.assertIs(order, actual) @@ -271,6 +275,7 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase): 13, site_ids=[9], warehouse_ids=[7], + scope=TestBatchScope.formal(), ) statement = self.db.execute.await_args.args[0] @@ -302,6 +307,7 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase): key, source_site_id=source_site_id, target_warehouse_id=target_warehouse_id, + scope=TestBatchScope.formal(), ) statement = self.db.execute.await_args.args[0] @@ -317,6 +323,18 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase): @unittest.skipUnless(ReturnService is not None, 'ReturnService 尚未实现') class ReturnServiceContractRedTest(unittest.TestCase): + def test_public_business_methods_require_explicit_test_scope(self) -> None: + for method_name in ( + 'get_return_list_services', + 'return_detail_services', + 'get_warehouse_options_services', + 'scan_return_pallet_services', + 'request_return_services', + 'confirm_return_services', + ): + with self.subTest(method=method_name): + self.assertIn('test_scope', inspect.signature(getattr(ReturnService, method_name)).parameters) + def test_service_exposes_complete_return_workflow(self) -> None: expected = { 'get_return_list_services', @@ -331,9 +349,9 @@ class ReturnServiceContractRedTest(unittest.TestCase): def test_request_and_confirm_signatures_are_exact(self) -> None: request_signature = inspect.signature(ReturnService.request_return_services) confirm_signature = inspect.signature(ReturnService.confirm_return_services) - self.assertEqual(('query_db', 'page_object', 'current_user'), tuple(request_signature.parameters)) + self.assertEqual(('query_db', 'page_object', 'current_user', 'test_scope'), tuple(request_signature.parameters)) self.assertEqual( - ('query_db', 'return_id', 'page_object', 'current_user'), + ('query_db', 'return_id', 'page_object', 'current_user', 'test_scope'), tuple(confirm_signature.parameters), ) request_hints = get_type_hints(ReturnService.request_return_services) @@ -493,15 +511,16 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 7])), patch.object(OperationDao, 'get_return_list', new=AsyncMock(return_value=page)) as get_list, ): - actual = await ReturnService.get_return_list_services(self.db, query, self.user) + actual = await ReturnService.get_return_list_services(self.db, query, self.user, TestBatchScope.formal()) self.assertEqual([], actual.rows) get_list.assert_awaited_once_with( self.db, query, - True, site_ids=[9], warehouse_ids=[7], + scope=TestBatchScope.formal(), + is_page=True, ) with ( @@ -509,7 +528,7 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])), patch.object(OperationDao, 'get_return_list', new=AsyncMock()) as empty_get_list, ): - empty = await ReturnService.get_return_list_services(self.db, query, self.user) + empty = await ReturnService.get_return_list_services(self.db, query, self.user, TestBatchScope.formal()) self.assertEqual([], empty.rows) empty_get_list.assert_not_awaited() @@ -523,7 +542,7 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'list_warehouse', new=AsyncMock(return_value=warehouses)), patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock()) as mappings, ): - actual = await ReturnService.get_warehouse_options_services(self.db, self.user) + actual = await ReturnService.get_warehouse_options_services(self.db, self.user, TestBatchScope.formal()) self.assertEqual([3, 7], [option.id for option in actual]) mappings.assert_not_awaited() @@ -543,10 +562,11 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): ' qr-2 ', ReturnScanQueryModel(mode='request'), self.user, + TestBatchScope.formal(), ) - get_pallet.assert_awaited_once_with(self.db, 'qr-2') - get_inventory.assert_awaited_once_with(self.db, 2) + get_pallet.assert_awaited_once_with(self.db, 'qr-2', TestBatchScope.formal()) + get_inventory.assert_awaited_once_with(self.db, 2, TestBatchScope.formal()) site_access.assert_awaited_once_with(self.db, self.user, 9) self.assertEqual(PalletFlowState.AT_SITE_PARTIAL, actual.flow_state) self.assertEqual(8, actual.remaining_packages) @@ -569,9 +589,10 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): 'qr-2', ReturnScanQueryModel(mode='confirm', returnId=13), self.user, + TestBatchScope.formal(), ) - get_order.assert_awaited_once_with(self.db, 13, site_ids=[], warehouse_ids=[7]) + get_order.assert_awaited_once_with(self.db, 13, TestBatchScope.formal(), site_ids=[], warehouse_ids=[7]) self.assertEqual(PalletFlowState.RETURNING, actual.flow_state) self.assertEqual(['confirm'], actual.allowed_actions) @@ -586,7 +607,7 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=changed)), self.assertRaises(ServiceException) as changed_error, ): - await ReturnService.scan_return_pallet_services(self.db, 'qr-2', query, self.user) + await ReturnService.scan_return_pallet_services(self.db, 'qr-2', query, self.user, TestBatchScope.formal()) self.assertIn('快照', changed_error.exception.message) with ( @@ -594,7 +615,7 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_return_by_id', new=AsyncMock(return_value=None)), self.assertRaises(PermissionException) as hidden_error, ): - await ReturnService.scan_return_pallet_services(self.db, 'qr-2', query, self.user) + await ReturnService.scan_return_pallet_services(self.db, 'qr-2', query, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该退回单', hidden_error.exception.message) async def test_confirm_scan_hides_missing_and_foreign_pallet_equally_for_normal_user(self) -> None: @@ -611,7 +632,9 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_pallet_inventory', new=inventory_read), ): try: - await ReturnService.scan_return_pallet_services(self.db, 'qr-5', query, self.user) + await ReturnService.scan_return_pallet_services( + self.db, 'qr-5', query, self.user, TestBatchScope.formal() + ) except Exception as exc: raised = exc else: @@ -638,7 +661,9 @@ class ReturnReadAndScanRedTest(unittest.IsolatedAsyncioTestCase): 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) + await ReturnService.scan_return_pallet_services( + self.db, 'qr-5', query, admin, TestBatchScope.formal() + ) self.assertEqual(expected_message, raised.exception.message) inventory_read.assert_not_awaited() @@ -679,11 +704,11 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): ) as site_access, patch.object(AccessService, 'assert_warehouse_access', new=warehouse_access), ): - await ReturnService._lock_new_request_route(self.db, self.page, self.user) + await ReturnService._lock_new_request_route(self.db, self.page, self.user, TestBatchScope.formal()) 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) + warehouse_read.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) + site_read.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True) site_access.assert_awaited_once_with(self.db, self.user, 9, for_update=True) warehouse_access.assert_not_awaited() @@ -752,7 +777,9 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)), ): try: - result = await ReturnService.request_return_services(self.db, self.page, self.user) + result = await ReturnService.request_return_services( + self.db, self.page, self.user, TestBatchScope.formal() + ) except RuntimeError as exc: self.fail(str(exc)) @@ -839,7 +866,9 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=AsyncMock()) as add_event, ): try: - result = await ReturnService.request_return_services(self.db, self.page, self.user) + result = await ReturnService.request_return_services( + self.db, self.page, self.user, TestBatchScope.formal() + ) except ServiceException as exc: self.fail(f'锁等待后的精确重试被库存状态提前拒绝:{exc.message}') @@ -896,7 +925,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): 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) + await ReturnService.request_return_services(self.db, conflict_page, self.user, TestBatchScope.formal()) self.assertEqual('申请退回失败,幂等键已被其他请求使用', raised.exception.message) self.assertEqual(2, key_read.await_count) @@ -942,7 +971,9 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=AsyncMock()) as add_event, ): try: - result = await ReturnService.request_return_services(self.db, self.page, self.user) + result = await ReturnService.request_return_services( + self.db, self.page, self.user, TestBatchScope.formal() + ) except PermissionException as exc: self.fail(f'已确认赢家在 fresh key 查询前被库存位置提前隐藏:{exc.message}') @@ -992,7 +1023,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'add_return_order', new=AsyncMock()) as add_order, ): try: - await ReturnService.request_return_services(self.db, conflict_page, self.user) + await ReturnService.request_return_services(self.db, conflict_page, self.user, TestBatchScope.formal()) except Exception as exc: raised = exc else: @@ -1032,13 +1063,21 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): ), patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), ): - result = await ReturnService.request_return_services(self.db, self.page, self.user) + result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(13, result.result) self.assertEqual( [ - {'source_site_id': 9, 'target_warehouse_id': 7}, - {'source_site_id': 9, 'target_warehouse_id': 7}, + { + 'source_site_id': 9, + 'target_warehouse_id': 7, + 'scope': TestBatchScope.formal(), + }, + { + 'source_site_id': 9, + 'target_warehouse_id': 7, + 'scope': TestBatchScope.formal(), + }, ], [call.kwargs for call in key_read.await_args_list], ) @@ -1062,10 +1101,10 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): 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) + await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', raised.exception.message) - inventory_locks.assert_awaited_once_with(self.db, [2, 5]) + inventory_locks.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) pallet_locks.assert_not_awaited() self.assertEqual(2, scope_read.await_count) self.assertEqual(1, key_read.await_count) @@ -1112,12 +1151,12 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): 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) + result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) 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=[]) + order_lock.assert_awaited_once_with(self.db, 13, TestBatchScope.formal(), site_ids=[9], warehouse_ids=[]) inventory_locks.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) @@ -1149,7 +1188,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_locks), ): try: - await ReturnService.request_return_services(db, self.page, self.user) + await ReturnService.request_return_services(db, self.page, self.user, TestBatchScope.formal()) except Exception as exc: raised = exc else: @@ -1192,7 +1231,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), ): try: - await ReturnService.request_return_services(self.db, self.page, self.user) + await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) except Exception as exc: raised = exc else: @@ -1285,7 +1324,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(return_service_module, 'datetime') as service_datetime, ): service_datetime.now.return_value = self.fixed_time - result = await ReturnService.request_return_services(self.db, self.page, self.user) + result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertTrue(result.is_success) self.assertEqual(13, result.result) @@ -1293,12 +1332,20 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(2, key_read.await_count) self.assertEqual( [ - {'source_site_id': 9, 'target_warehouse_id': 7}, - {'source_site_id': 9, 'target_warehouse_id': 7}, + { + 'source_site_id': 9, + 'target_warehouse_id': 7, + 'scope': TestBatchScope.formal(), + }, + { + 'source_site_id': 9, + 'target_warehouse_id': 7, + 'scope': TestBatchScope.formal(), + }, ], [call.kwargs for call in key_read.await_args_list], ) - locks.assert_awaited_once_with(self.db, [2, 5]) + locks.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) site_access.assert_awaited_with(self.db, self.user, 9, for_update=True) warehouse_access.assert_not_awaited() self.assertEqual(ReturnStatus.REQUESTED.value, captured['order'].status) @@ -1339,7 +1386,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'add_return_order', new=AsyncMock()) as add_order, self.assertRaises(PermissionException) as raised, ): - await ReturnService.request_return_services(self.db, self.page, self.user) + await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', raised.exception.message) add_order.assert_not_awaited() @@ -1370,7 +1417,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): 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) + await ReturnService.request_return_services(db, self.page, admin, TestBatchScope.formal()) self.assertEqual(expected_message, raised.exception.message) pallet_locks.assert_not_awaited() db.commit.assert_not_awaited() @@ -1391,7 +1438,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as locks, ): - result = await ReturnService.request_return_services(self.db, self.page, self.user) + result = await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(13, result.result) locks.assert_not_awaited() self.db.commit.assert_not_awaited() @@ -1408,7 +1455,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(AccessService, 'assert_site_access', new=AsyncMock()), self.assertRaises(ServiceException) as raised, ): - await ReturnService.request_return_services(conflict_db, self.page, self.user) + await ReturnService.request_return_services(conflict_db, self.page, self.user, TestBatchScope.formal()) self.assertIn('幂等键已被其他请求使用', raised.exception.message) conflict_db.commit.assert_not_awaited() conflict_db.rollback.assert_awaited() @@ -1435,7 +1482,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[])), ): try: - await ReturnService.request_return_services(self.db, self.page, self.user) + await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) except Exception as exc: raised = exc else: @@ -1458,7 +1505,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): 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) + await ReturnService.request_return_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', raised.exception.message) key_read.assert_not_awaited() @@ -1483,6 +1530,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): self.page, request_hash, self.user, + TestBatchScope.formal(), ) self.assertEqual(13, result.result) @@ -1491,10 +1539,12 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): 'request-key', source_site_id=9, target_warehouse_id=7, + scope=TestBatchScope.formal(), ) order_lock.assert_awaited_once_with( self.db, 13, + TestBatchScope.formal(), site_ids=[9, 10], warehouse_ids=[], ) @@ -1511,7 +1561,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(AccessService, 'assert_site_access', new=AsyncMock()), patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), ): - result = await ReturnService.request_return_services(self.db, self.page, admin) + result = await ReturnService.request_return_services(self.db, self.page, admin, TestBatchScope.formal()) self.assertEqual(13, result.result) get_by_key.assert_awaited_once_with( @@ -1519,6 +1569,7 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase): 'request-key', source_site_id=9, target_warehouse_id=7, + scope=TestBatchScope.formal(), ) @@ -1583,12 +1634,14 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(return_service_module, 'datetime') as service_datetime, ): service_datetime.now.return_value = self.fixed_time - result = await ReturnService.confirm_return_services(self.db, 13, self.page, self.user) + result = await ReturnService.confirm_return_services( + self.db, 13, self.page, self.user, TestBatchScope.formal() + ) self.assertTrue(result.is_success) self.assertEqual(13, result.result) - lock_order.assert_awaited_once_with(self.db, 13, site_ids=[], warehouse_ids=[7]) - locks.assert_awaited_once_with(self.db, [2, 5]) + lock_order.assert_awaited_once_with(self.db, 13, TestBatchScope.formal(), site_ids=[], warehouse_ids=[7]) + locks.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) warehouse_access.assert_awaited_with(self.db, self.user, 7, for_update=True) self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE.value, inventories[0].flow_state) self.assertEqual(102, inventories[0].active_filling_id) @@ -1633,7 +1686,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[])), ): try: - await ReturnService.confirm_return_services(self.db, 13, self.page, self.user) + await ReturnService.confirm_return_services(self.db, 13, self.page, self.user, TestBatchScope.formal()) except ServiceException: pass @@ -1643,6 +1696,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): 'confirm-key', source_site_id=9, target_warehouse_id=7, + scope=TestBatchScope.formal(), ) async def test_confirm_requires_exact_scan_set_before_inventory_locks(self) -> None: @@ -1664,7 +1718,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as locks, self.assertRaises(ServiceException) as raised, ): - await ReturnService.confirm_return_services(db, 13, page, self.user) + await ReturnService.confirm_return_services(db, 13, page, self.user, TestBatchScope.formal()) self.assertIn('扫码', raised.exception.message) locks.assert_not_awaited() db.rollback.assert_awaited() @@ -1699,7 +1753,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'update_return_order', new=AsyncMock()) as update_order, self.assertRaises(ServiceException) as raised, ): - await ReturnService.confirm_return_services(db, 13, page, self.user) + await ReturnService.confirm_return_services(db, 13, page, self.user, TestBatchScope.formal()) self.assertTrue('快照' in raised.exception.message or '状态' in raised.exception.message) update_order.assert_not_awaited() db.commit.assert_not_awaited() @@ -1725,7 +1779,9 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock()) as locks, ): - result = await ReturnService.confirm_return_services(self.db, 13, self.page, self.user) + result = await ReturnService.confirm_return_services( + self.db, 13, self.page, self.user, TestBatchScope.formal() + ) self.assertEqual(13, result.result) locks.assert_not_awaited() self.db.commit.assert_not_awaited() @@ -1744,7 +1800,9 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): ), self.assertRaises(ServiceException) as raised, ): - await ReturnService.confirm_return_services(conflict_db, 13, conflict_page, self.user) + await ReturnService.confirm_return_services( + conflict_db, 13, conflict_page, self.user, TestBatchScope.formal() + ) self.assertIn('幂等键已被其他请求使用', raised.exception.message) conflict_db.rollback.assert_awaited() @@ -1769,12 +1827,13 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()), patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))), ): - result = await ReturnService.confirm_return_services(self.db, 13, self.page, admin) + result = await ReturnService.confirm_return_services(self.db, 13, self.page, admin, TestBatchScope.formal()) self.assertEqual(13, result.result) get_order.assert_awaited_once_with( self.db, 13, + TestBatchScope.formal(), site_ids=None, warehouse_ids=None, ) @@ -1783,6 +1842,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): 'confirm-key', source_site_id=9, target_warehouse_id=7, + scope=TestBatchScope.formal(), ) async def test_confirm_integrity_recovery_resolves_route_before_exact_key_lookup(self) -> None: @@ -1819,18 +1879,20 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): self.page, confirm_hash, self.user, + TestBatchScope.formal(), ) self.assertEqual(13, result.result) self.assertEqual(['return', 'key'], call_order) - route_read.assert_awaited_once_with(self.db, 13, site_ids=[], warehouse_ids=[7, 8]) + route_read.assert_awaited_once_with(self.db, 13, TestBatchScope.formal(), site_ids=[], warehouse_ids=[7, 8]) key_read.assert_awaited_once_with( self.db, 'confirm-key', source_site_id=9, target_warehouse_id=7, + scope=TestBatchScope.formal(), ) - order_lock.assert_awaited_once_with(self.db, 13, site_ids=[], warehouse_ids=[7, 8]) + order_lock.assert_awaited_once_with(self.db, 13, TestBatchScope.formal(), site_ids=[], warehouse_ids=[7, 8]) self.db.rollback.assert_awaited_once() async def test_confirm_rechecks_warehouse_mapping_before_business_locks(self) -> None: @@ -1857,7 +1919,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[])), ): try: - await ReturnService.confirm_return_services(self.db, 13, self.page, self.user) + await ReturnService.confirm_return_services(self.db, 13, self.page, self.user, TestBatchScope.formal()) except Exception as exc: raised = exc else: diff --git a/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py b/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py index e2f171f..10b9b68 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py @@ -23,7 +23,7 @@ from common.vo import ( PageResponseModel, ResponseBaseModel, ) -from exceptions.exception import PermissionException, ServiceException +from exceptions.exception import PermissionException, ServiceException, ServiceWarning from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel from module_admin.service.login_service import LoginService from module_business.controller import shipment_controller as shipment_controller_module @@ -33,6 +33,7 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizWarehouse from module_business.entity.do.operation_do import ( BizFillingRecord, @@ -152,6 +153,21 @@ def make_shipment( class ShipmentServiceBootstrapTest(unittest.TestCase): + def test_public_business_methods_require_explicit_test_scope(self) -> None: + for method_name in ( + 'get_shipment_list_services', + 'shipment_detail_services', + 'get_warehouse_options_services', + 'get_site_options_services', + 'scan_shipment_pallet_services', + 'add_shipment_services', + 'edit_shipment_services', + 'delete_shipment_services', + 'confirm_shipment_services', + ): + with self.subTest(method=method_name): + self.assertIn('test_scope', inspect.signature(getattr(ShipmentService, method_name)).parameters) + def test_shipment_service_module_exists(self) -> None: spec = importlib.util.find_spec('module_business.service.shipment_service') @@ -164,7 +180,7 @@ class ShipmentServiceBootstrapTest(unittest.TestCase): ) signature = inspect.signature(ShipmentService.confirm_shipment_services) self.assertEqual( - ('query_db', 'shipment_id', 'page_object', 'current_user'), + ('query_db', 'shipment_id', 'page_object', 'current_user', 'test_scope'), tuple(signature.parameters), ) hints = get_type_hints(ShipmentService.confirm_shipment_services) @@ -215,25 +231,29 @@ class ShipmentInfrastructureContractTest(unittest.TestCase): OperationDao.get_shipment_list: ( 'db', 'query_object', - 'is_page', 'warehouse_ids', 'site_ids', + 'scope', + 'is_page', ), OperationDao.get_shipment_by_id: ( 'db', 'shipment_id', + 'scope', 'warehouse_ids', 'site_ids', ), OperationDao.get_shipment_for_update: ( 'db', 'shipment_id', + 'scope', 'warehouse_ids', 'site_ids', ), OperationDao.get_shipment_by_confirm_idempotency_key: ( 'db', 'idempotency_key', + 'scope', 'warehouse_ids', 'site_ids', ), @@ -271,6 +291,7 @@ class ShipmentDetailDaoTest(unittest.IsolatedAsyncioTestCase): 12, warehouse_ids=[7], site_ids=[9], + scope=TestBatchScope.formal(), ) self.assertIs(shipment, actual) @@ -322,6 +343,7 @@ class ShipmentDetailDaoTest(unittest.IsolatedAsyncioTestCase): 12, warehouse_ids=[7], site_ids=[9], + scope=TestBatchScope.formal(), ) self.assertIs(shipment, actual) @@ -472,6 +494,7 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): self.db, query, self.user, + TestBatchScope.formal(), is_page=True, ) except NotImplementedError: @@ -500,9 +523,10 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): self.db, ShipmentPageQueryModel(), self.user, + TestBatchScope.formal(), ) try: - detail = await ShipmentService.shipment_detail_services(self.db, 12, self.user) + detail = await ShipmentService.shipment_detail_services(self.db, 12, self.user, TestBatchScope.formal()) except NotImplementedError: self.fail('双范围单据详情尚未实现') @@ -511,13 +535,15 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): get_list.assert_awaited_once_with( self.db, unittest.mock.ANY, - True, warehouse_ids=[7], site_ids=[9], + scope=TestBatchScope.formal(), + is_page=True, ) get_detail.assert_awaited_once_with( self.db, 12, + TestBatchScope.formal(), warehouse_ids=[7], site_ids=[9], ) @@ -540,8 +566,12 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'list_site', new=AsyncMock(return_value=sites)), ): try: - warehouse_options = await ShipmentService.get_warehouse_options_services(self.db, self.user) - site_options = await ShipmentService.get_site_options_services(self.db, self.user) + warehouse_options = await ShipmentService.get_warehouse_options_services( + self.db, self.user, TestBatchScope.formal() + ) + site_options = await ShipmentService.get_site_options_services( + self.db, self.user, TestBatchScope.formal() + ) except NotImplementedError: self.fail('发货仓库/现场选项尚未实现') @@ -598,6 +628,7 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): 'qr-2', query, self.user, + TestBatchScope.formal(), ) except NotImplementedError: self.fail('整托发货扫码尚未实现') @@ -606,7 +637,11 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE, result.flow_state) self.assertEqual(8, result.remaining_packages) self.assertEqual(['shipment'], result.allowed_actions) - get_filling.assert_awaited_once_with(self.db, inventory.active_filling_id) + get_filling.assert_awaited_once_with( + self.db, + inventory.active_filling_id, + TestBatchScope.formal(), + ) lock_inventory.assert_not_awaited() lock_pallets.assert_not_awaited() lock_filling.assert_not_awaited() @@ -627,7 +662,7 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_shipment_list', new=get_list), self.assertRaises(PermissionException) as raised, ): - await ShipmentService.get_shipment_list_services(self.db, query, self.user) + await ShipmentService.get_shipment_list_services(self.db, query, self.user, TestBatchScope.formal()) self.assertEqual(expected_message, raised.exception.message) get_list.assert_not_awaited() @@ -640,12 +675,13 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_shipment_by_id', new=get_detail), self.assertRaises(PermissionException) as user_raised, ): - await ShipmentService.shipment_detail_services(self.db, 404, self.user) + await ShipmentService.shipment_detail_services(self.db, 404, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该发货单', user_raised.exception.message) get_detail.assert_awaited_once_with( self.db, 404, + TestBatchScope.formal(), warehouse_ids=[7], site_ids=[9], ) @@ -655,11 +691,12 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_shipment_by_id', new=get_detail), self.assertRaises(ServiceException) as admin_raised, ): - await ShipmentService.shipment_detail_services(self.db, 404, make_user(1)) + await ShipmentService.shipment_detail_services(self.db, 404, make_user(1), TestBatchScope.formal()) self.assertEqual('发货单不存在', admin_raised.exception.message) get_detail.assert_awaited_once_with( self.db, 404, + TestBatchScope.formal(), warehouse_ids=None, site_ids=None, ) @@ -715,6 +752,7 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): 'hidden-token', ShipmentScanQueryModel(sourceWarehouseId=7, targetSiteId=9), self.user, + TestBatchScope.formal(), ) self.assertEqual('无权访问该托盘', raised.exception.message) @@ -747,6 +785,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): self.db, [2], 7, + TestBatchScope.formal(), ) self.assertEqual([inventory], inventories) @@ -794,7 +833,11 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): actions.append('pallet-lock') return pallets - async def lock_filling(_db: AsyncSession, filling_id: int) -> BizFillingRecord: + async def lock_filling( + _db: AsyncSession, + filling_id: int, + _scope: TestBatchScope, + ) -> BizFillingRecord: actions.append(f'filling-lock-{filling_id}') return fillings[filling_id] @@ -844,7 +887,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): service_datetime.now.return_value = self.fixed_time service_secrets.token_hex.return_value = 'draft-token' try: - result = await ShipmentService.add_shipment_services(self.db, page, self.user) + result = await ShipmentService.add_shipment_services(self.db, page, self.user, TestBatchScope.formal()) except NotImplementedError: self.fail('发货草稿新增尚未实现') @@ -854,8 +897,8 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual([2, 5], [item.pallet_id for item in captured['items']]) self.assertTrue(all(item.active_filling_id is None for item in captured['items'])) self.assertTrue(all(item.package_qty is None for item in captured['items'])) - inventory_lock.assert_awaited_once_with(self.db, [2, 5]) - pallet_lock.assert_awaited_once_with(self.db, [2, 5]) + inventory_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) + pallet_lock.assert_awaited_once_with(self.db, [2, 5], TestBatchScope.formal()) self.assertEqual( [ 'warehouse-access', @@ -928,11 +971,11 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'replace_shipment_items', new=replace_items), self.assertRaises(PermissionException) as raised, ): - await ShipmentService.add_shipment_services(self.db, page, self.user) + await ShipmentService.add_shipment_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该仓库', raised.exception.message) self.assertEqual(2, warehouse_read.await_count) - warehouse_read.assert_awaited_with(self.db, 7, for_update=True) + warehouse_read.assert_awaited_with(self.db, 7, TestBatchScope.formal(), for_update=True) self.assertEqual(2, warehouse_mapping.await_count) warehouse_mapping.assert_awaited_with(self.db, self.user, 7, for_update=True) add_order.assert_not_awaited() @@ -980,10 +1023,10 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'replace_shipment_items', new=replace_items), self.assertRaises(ServiceException) as raised, ): - await ShipmentService.add_shipment_services(self.db, page, self.user) + await ShipmentService.add_shipment_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual('来源仓库已停用', raised.exception.message) - warehouse_read.assert_awaited_with(self.db, 7, for_update=True) + warehouse_read.assert_awaited_with(self.db, 7, TestBatchScope.formal(), for_update=True) add_order.assert_not_awaited() replace_items.assert_not_awaited() self.db.commit.assert_not_awaited() @@ -1062,7 +1105,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): ): service_datetime.now.return_value = self.fixed_time try: - result = await ShipmentService.edit_shipment_services(self.db, page, self.user) + result = await ShipmentService.edit_shipment_services(self.db, page, self.user, TestBatchScope.formal()) except NotImplementedError: self.fail('发货草稿编辑尚未实现') @@ -1070,6 +1113,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): lock_order.assert_awaited_once_with( self.db, 12, + TestBatchScope.formal(), warehouse_ids=[7, 8], site_ids=[9, 10], ) @@ -1129,6 +1173,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): async def get_warehouse( _db: AsyncSession, warehouse_id: int, + _scope: TestBatchScope, **_kwargs: Any, ) -> BizWarehouse: return BizWarehouse( @@ -1141,6 +1186,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): async def get_site( _db: AsyncSession, site_id: int, + _scope: TestBatchScope, _scenario: str = scenario, _new_site_id: int = new_site_id, **kwargs: Any, @@ -1179,13 +1225,13 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'replace_shipment_items', new=replace_items), self.assertRaises(exception_type) as raised, ): - await ShipmentService.edit_shipment_services(db, page, self.user) + await ShipmentService.edit_shipment_services(db, page, self.user, TestBatchScope.formal()) self.assertEqual(expected_message, raised.exception.message) if scenario == 'current-warehouse-revoked': warehouse_mapping.assert_any_await(db, self.user, 7, for_update=True) else: - site_read.assert_any_await(db, 10, for_update=True) + site_read.assert_any_await(db, 10, TestBatchScope.formal(), for_update=True) if scenario == 'new-site-revoked': site_mapping.assert_any_await(db, self.user, 10, for_update=True) update.assert_not_awaited() @@ -1263,6 +1309,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): async def get_warehouse( _db: AsyncSession, warehouse_id: int, + _scope: TestBatchScope, _disabled_ids: set[int] = disabled_warehouse_ids, _locks: list[str] = locks, **kwargs: Any, @@ -1279,6 +1326,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): async def get_site( _db: AsyncSession, site_id: int, + _scope: TestBatchScope, _disabled_ids: set[int] = disabled_site_ids, _locks: list[str] = locks, **kwargs: Any, @@ -1316,7 +1364,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'replace_shipment_items', new=replace_items), self.assertRaises(PermissionException) as raised, ): - await ShipmentService.edit_shipment_services(db, page, self.user) + await ShipmentService.edit_shipment_services(db, page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该发货单', raised.exception.message) self.assertEqual(expected_locks, locks) @@ -1361,6 +1409,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): async def get_warehouse( _db: AsyncSession, warehouse_id: int, + _scope: TestBatchScope, **kwargs: Any, ) -> BizWarehouse: if kwargs.get('for_update'): @@ -1375,6 +1424,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): async def get_site( _db: AsyncSession, site_id: int, + _scope: TestBatchScope, **kwargs: Any, ) -> BizCustomerSite: if kwargs.get('for_update'): @@ -1415,7 +1465,7 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'update_shipment', new=AsyncMock(side_effect=update)), patch.object(OperationDao, 'replace_shipment_items', new=AsyncMock(side_effect=replace)), ): - result = await ShipmentService.edit_shipment_services(db, page, self.user) + result = await ShipmentService.edit_shipment_services(db, page, self.user, TestBatchScope.formal()) self.assertTrue(result.is_success) self.assertEqual( @@ -1437,222 +1487,55 @@ class ShipmentDraftServiceTest(unittest.IsolatedAsyncioTestCase): ) async def test_batch_delete_preflights_all_drafts_before_any_delete(self) -> None: - documents = { - 11: make_shipment(11, status='DRAFT'), - 12: make_shipment(12, status='CONFIRMED'), - } - - async def lock_order( - _db: AsyncSession, - shipment_id: int, - **_kwargs: Any, - ) -> BizShipmentOrder: - return documents[shipment_id] - + lock = AsyncMock() delete = AsyncMock() with ( - patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])), - patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), - patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(side_effect=lock_order)) as lock, + patch.object(OperationDao, 'get_shipment_for_update', new=lock), patch.object(OperationDao, 'delete_shipment', new=delete), + self.assertRaises(ServiceWarning) as raised, ): - try: - with self.assertRaises(ServiceException): - await ShipmentService.delete_shipment_services(self.db, [12, 11], self.user) - except NotImplementedError: - self.fail('发货草稿原子批量删除尚未实现') + await ShipmentService.delete_shipment_services(self.db, [12, 11], self.user, TestBatchScope.formal()) - self.assertEqual([11, 12], [call.args[1] for call in lock.await_args_list]) + self.assertEqual('不支持单条物理删除,请通过测试数据批次清理', raised.exception.message) + lock.assert_not_awaited() delete.assert_not_awaited() self.db.commit.assert_not_awaited() - self.db.rollback.assert_awaited_once() async def test_batch_delete_rechecks_all_current_routes_before_first_delete(self) -> None: - documents = { - 11: make_shipment(11, source_warehouse_id=8, target_site_id=10, status='DRAFT'), - 12: make_shipment(12, source_warehouse_id=7, target_site_id=9, status='DRAFT'), - } - revoked_warehouse_id = documents[11].source_warehouse_id - parent_lock_order: list[int] = [] - - async def lock_order( - _db: AsyncSession, - shipment_id: int, - **_kwargs: Any, - ) -> BizShipmentOrder: - parent_lock_order.append(shipment_id) - return documents[shipment_id] - - async def get_warehouse( - _db: AsyncSession, - warehouse_id: int, - **_kwargs: Any, - ) -> BizWarehouse: - return BizWarehouse( - warehouse_id=warehouse_id, - warehouse_code=f'W-{warehouse_id}', - warehouse_name=f'{warehouse_id}号仓', - status='0', - ) - - async def get_site( - _db: AsyncSession, - site_id: int, - **_kwargs: Any, - ) -> BizCustomerSite: - return BizCustomerSite( - site_id=site_id, - customer_id=1, - site_code=f'S-{site_id}', - site_name=f'{site_id}号现场', - status='0', - ) - - async def warehouse_access( - _db: AsyncSession, - _user: CurrentUserModel, - warehouse_id: int, - **kwargs: Any, - ) -> None: - if warehouse_id == revoked_warehouse_id and kwargs.get('for_update'): - raise PermissionException(data='', message='无权访问该仓库') - + lock = AsyncMock() delete = AsyncMock() - warehouse_mapping = AsyncMock(side_effect=warehouse_access) - site_lock = AsyncMock(side_effect=get_site) - site_mapping = AsyncMock() with ( - patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])), - patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])), - patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(side_effect=lock_order)), - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=get_warehouse)), - patch.object(AccessService, 'assert_warehouse_access', new=warehouse_mapping), - patch.object(MasterDao, 'get_site', new=site_lock), - patch.object(AccessService, 'assert_site_access', new=site_mapping), + patch.object(OperationDao, 'get_shipment_for_update', new=lock), patch.object(OperationDao, 'delete_shipment', new=delete), - self.assertRaises(PermissionException) as raised, + self.assertRaises(ServiceWarning) as raised, ): - await ShipmentService.delete_shipment_services(self.db, [12, 11], self.user) + await ShipmentService.delete_shipment_services(self.db, [12, 11], self.user, TestBatchScope.formal()) - self.assertEqual('无权访问该发货单', raised.exception.message) - self.assertEqual([11, 12], parent_lock_order) - self.assertEqual([7, 8], [call.args[2] for call in warehouse_mapping.await_args_list]) - self.assertTrue(all(call.kwargs.get('for_update') for call in warehouse_mapping.await_args_list)) - self.assertEqual([9, 10], [call.args[1] for call in site_lock.await_args_list]) - self.assertEqual([9, 10], [call.args[2] for call in site_mapping.await_args_list]) - self.assertTrue(all(call.kwargs.get('for_update') for call in site_mapping.await_args_list)) + self.assertEqual('不支持单条物理删除,请通过测试数据批次清理', raised.exception.message) + lock.assert_not_awaited() delete.assert_not_awaited() self.db.commit.assert_not_awaited() - self.db.rollback.assert_awaited_once() async def test_batch_delete_locks_deduplicated_routes_stably_without_requiring_active(self) -> None: db = MagicMock(spec=AsyncSession) - documents = { - 11: make_shipment(11, source_warehouse_id=8, target_site_id=10, status='DRAFT'), - 12: make_shipment(12, source_warehouse_id=7, target_site_id=10, status='DRAFT'), - 13: make_shipment(13, source_warehouse_id=8, target_site_id=9, status='DRAFT'), - } - actions: list[str] = [] - - async def lock_order( - _db: AsyncSession, - shipment_id: int, - **_kwargs: Any, - ) -> BizShipmentOrder: - actions.append(f'parent-{shipment_id}') - return documents[shipment_id] - - async def get_warehouse( - _db: AsyncSession, - warehouse_id: int, - **kwargs: Any, - ) -> BizWarehouse: - self.assertTrue(kwargs.get('for_update')) - actions.append(f'warehouse-master-{warehouse_id}') - return BizWarehouse( - warehouse_id=warehouse_id, - warehouse_code=f'W-{warehouse_id}', - warehouse_name=f'{warehouse_id}号仓', - status='1', - ) - - async def warehouse_access( - _db: AsyncSession, - _user: CurrentUserModel, - warehouse_id: int, - **kwargs: Any, - ) -> None: - self.assertTrue(kwargs.get('for_update')) - actions.append(f'warehouse-mapping-{warehouse_id}') - - async def get_site( - _db: AsyncSession, - site_id: int, - **kwargs: Any, - ) -> BizCustomerSite: - self.assertTrue(kwargs.get('for_update')) - actions.append(f'site-master-{site_id}') - return BizCustomerSite( - site_id=site_id, - customer_id=1, - site_code=f'S-{site_id}', - site_name=f'{site_id}号现场', - status='1', - ) - - async def site_access( - _db: AsyncSession, - _user: CurrentUserModel, - site_id: int, - **kwargs: Any, - ) -> None: - self.assertTrue(kwargs.get('for_update')) - actions.append(f'site-mapping-{site_id}') - - async def delete(_db: AsyncSession, shipment_id: int) -> None: - actions.append(f'delete-{shipment_id}') - - async def commit() -> None: - actions.append('commit') - - db.commit.side_effect = commit + lock = AsyncMock() + delete = AsyncMock() with ( - patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])), - patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])), - patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(side_effect=lock_order)), - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(side_effect=get_warehouse)), - patch.object( - AccessService, - 'assert_warehouse_access', - new=AsyncMock(side_effect=warehouse_access), - ), - patch.object(MasterDao, 'get_site', new=AsyncMock(side_effect=get_site)), - patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=site_access)), - patch.object(OperationDao, 'delete_shipment', new=AsyncMock(side_effect=delete)), + patch.object(OperationDao, 'get_shipment_for_update', new=lock), + patch.object(OperationDao, 'delete_shipment', new=delete), + self.assertRaises(ServiceWarning) as raised, ): - result = await ShipmentService.delete_shipment_services(db, [13, 11, 12], self.user) + await ShipmentService.delete_shipment_services( + db, + [13, 11, 12], + self.user, + TestBatchScope.formal(), + ) - self.assertTrue(result.is_success) - self.assertEqual( - [ - 'parent-11', - 'parent-12', - 'parent-13', - 'warehouse-master-7', - 'warehouse-mapping-7', - 'warehouse-master-8', - 'warehouse-mapping-8', - 'site-master-9', - 'site-mapping-9', - 'site-master-10', - 'site-mapping-10', - 'delete-11', - 'delete-12', - 'delete-13', - 'commit', - ], - actions, - ) + self.assertEqual('不支持单条物理删除,请通过测试数据批次清理', raised.exception.message) + lock.assert_not_awaited() + delete.assert_not_awaited() + db.commit.assert_not_awaited() class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): @@ -1695,7 +1578,11 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): actions.append('pallet-lock') return pallets - async def filling_lock(_db: AsyncSession, filling_id: int) -> BizFillingRecord: + async def filling_lock( + _db: AsyncSession, + filling_id: int, + _scope: TestBatchScope, + ) -> BizFillingRecord: actions.append(f'filling-lock-{filling_id}') return fillings[filling_id] @@ -1791,6 +1678,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): 12, self.command, self.user, + TestBatchScope.formal(), ) except NotImplementedError: self.fail('多托盘整托确认尚未实现') @@ -1821,6 +1709,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): detail_read.assert_awaited_once_with( self.db, 12, + TestBatchScope.formal(), warehouse_ids=[7], site_ids=[9], ) @@ -1923,12 +1812,13 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): 12, self.command, self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) - warehouse_lock.assert_awaited_once_with(self.db, 7, for_update=True) + warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) warehouse_access.assert_awaited_once_with(self.db, self.user, 7, for_update=True) - site_lock.assert_awaited_once_with(self.db, 9, for_update=True) + site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True) site_access.assert_awaited_once_with(self.db, self.user, 9, for_update=True) inventory_lock.assert_not_awaited() update.assert_not_awaited() @@ -2009,12 +1899,13 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): 12, self.command, self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) - warehouse_lock.assert_awaited_once_with(db, 7, for_update=True) + warehouse_lock.assert_awaited_once_with(db, 7, TestBatchScope.formal(), for_update=True) warehouse_access.assert_awaited_once_with(db, self.user, 7, for_update=True) - site_lock.assert_awaited_once_with(db, 9, for_update=True) + site_lock.assert_awaited_once_with(db, 9, TestBatchScope.formal(), for_update=True) site_access.assert_awaited_once_with(db, self.user, 9, for_update=True) self.assertEqual( original_snapshots, @@ -2081,12 +1972,13 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): 12, self.command, self.user, + TestBatchScope.formal(), ) self.assertEqual('无权访问该发货单', raised.exception.message) - warehouse_lock.assert_awaited_once_with(self.db, 7, for_update=True) + warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) warehouse_mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True) - site_lock.assert_awaited_once_with(self.db, 9, for_update=True) + site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True) site_mapping.assert_awaited_once_with(self.db, self.user, 9, for_update=True) inventory_lock.assert_not_awaited() update.assert_not_awaited() @@ -2146,10 +2038,11 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): 12, self.command, self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) - lock_validate.assert_awaited_once_with(self.db, [2], 7) + lock_validate.assert_awaited_once_with(self.db, [2], 7, TestBatchScope.formal()) self.assertEqual((102, 6), (locked.items[0].active_filling_id, locked.items[0].package_qty)) self.assertIsNone(initial.items[0].active_filling_id) self.assertIsNone(initial.items[0].package_qty) @@ -2175,7 +2068,9 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_shipment_for_update', new=parent_lock), self.assertRaises(ServiceException) as raised, ): - await ShipmentService.confirm_shipment_services(self.db, 12, self.command, self.user) + await ShipmentService.confirm_shipment_services( + self.db, 12, self.command, self.user, TestBatchScope.formal() + ) self.assertEqual('确认发货失败,幂等键已被其他请求使用', raised.exception.message) parent_lock.assert_not_awaited() @@ -2199,6 +2094,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): async def read_key( _db: AsyncSession, _key: str, + _scope: TestBatchScope, *, warehouse_ids: list[int] | None = None, site_ids: list[int] | None = None, @@ -2254,18 +2150,21 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): 12, self.command, self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) get_detail.assert_awaited_once_with( self.db, 12, + TestBatchScope.formal(), warehouse_ids=[7, 8], site_ids=[9, 10], ) key_read.assert_awaited_once_with( self.db, 'confirm-key', + TestBatchScope.formal(), warehouse_ids=[7], site_ids=[9], ) @@ -2295,7 +2194,9 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): ), self.assertRaises(ServiceException) as raised, ): - await ShipmentService.confirm_shipment_services(self.db, 12, self.command, self.user) + await ShipmentService.confirm_shipment_services( + self.db, 12, self.command, self.user, TestBatchScope.formal() + ) self.assertEqual('确认发货失败,幂等键已被其他请求使用', raised.exception.message) self.assertEqual(2, self.db.rollback.await_count) @@ -2359,19 +2260,22 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(AccessService, 'assert_site_access', new=AsyncMock()), self.assertRaises(PermissionException) as raised, ): - await ShipmentService.confirm_shipment_services(self.db, 12, self.command, self.user) + await ShipmentService.confirm_shipment_services( + self.db, 12, self.command, self.user, TestBatchScope.formal() + ) self.assertEqual('无权访问该发货单', raised.exception.message) self.assertEqual(2, parent_lock.await_count) parent_lock.assert_awaited_with( self.db, 12, + TestBatchScope.formal(), warehouse_ids=[7], site_ids=[9], ) - warehouse_lock.assert_awaited_once_with(self.db, 7, for_update=True) + warehouse_lock.assert_awaited_once_with(self.db, 7, TestBatchScope.formal(), for_update=True) warehouse_mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True) - site_lock.assert_awaited_once_with(self.db, 9, for_update=True) + site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True) self.db.commit.assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) @@ -2425,14 +2329,16 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(ServiceException) as raised, ): - await ShipmentService.confirm_shipment_services(self.db, 12, self.command, self.user) + await ShipmentService.confirm_shipment_services( + self.db, 12, self.command, self.user, TestBatchScope.formal() + ) self.assertEqual('来源仓库已停用', raised.exception.message) self.assertEqual('DRAFT', shipment.status) self.assertEqual('FILLED_IN_WAREHOUSE', inventory.flow_state) self.assertEqual(4, inventory.version) mapping.assert_awaited_once_with(self.db, self.user, 7, for_update=True) - site_lock.assert_awaited_once_with(self.db, 9, for_update=True) + site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True) site_mapping.assert_awaited_once_with(self.db, self.user, 9, for_update=True) update.assert_not_awaited() add_event.assert_not_awaited() @@ -2491,6 +2397,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): 12, self.command, self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) @@ -2523,6 +2430,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): async def read_key( _db: AsyncSession, _key: str, + _scope: TestBatchScope, *, warehouse_ids: list[int] | None = None, site_ids: list[int] | None = None, @@ -2584,6 +2492,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): 12, self.command, self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) @@ -2595,12 +2504,14 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): unittest.mock.call( self.db, 'confirm-key', + TestBatchScope.formal(), warehouse_ids=[7], site_ids=[9], ), unittest.mock.call( self.db, 'confirm-key', + TestBatchScope.formal(), warehouse_ids=[7], site_ids=[9], ), @@ -2667,7 +2578,9 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaises(PermissionException) as raised, ): - await ShipmentService.confirm_shipment_services(self.db, 12, self.command, self.user) + await ShipmentService.confirm_shipment_services( + self.db, 12, self.command, self.user, TestBatchScope.formal() + ) self.assertEqual('无权访问该发货单', raised.exception.message) self.assertEqual('DRAFT', shipment.status) @@ -2675,7 +2588,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(7, inventory.warehouse_id) self.assertIsNone(inventory.site_id) self.assertEqual(4, inventory.version) - site_lock.assert_awaited_once_with(self.db, 9, for_update=True) + site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True) site_mapping.assert_awaited_once_with(self.db, self.user, 9, for_update=True) transition.assert_not_called() update.assert_not_awaited() @@ -2690,7 +2603,11 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): update = AsyncMock() add_event = AsyncMock() - async def get_filling(_db: AsyncSession, filling_id: int) -> BizFillingRecord: + async def get_filling( + _db: AsyncSession, + filling_id: int, + _scope: TestBatchScope, + ) -> BizFillingRecord: return fillings[filling_id] transition = MagicMock(side_effect=[None, ValueError('second pallet invalid')]) @@ -2747,7 +2664,9 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'add_event', new=add_event), self.assertRaisesRegex(ValueError, 'second pallet invalid'), ): - await ShipmentService.confirm_shipment_services(self.db, 12, self.command, self.user) + await ShipmentService.confirm_shipment_services( + self.db, 12, self.command, self.user, TestBatchScope.formal() + ) self.assertEqual('DRAFT', shipment.status) self.assertTrue(all(item.active_filling_id is None and item.package_qty is None for item in shipment.items)) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py b/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py index c97be06..2d0824f 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py @@ -31,6 +31,7 @@ from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.domain.enums import PalletEventType, PalletFlowState +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine from module_business.entity.do.operation_do import ( BizFillingRecord, @@ -48,6 +49,7 @@ from module_business.entity.vo.operation_vo import ( UsageSuccessResponseModel, ) from module_business.service.access_service import AccessService +from tests.module_business.test_batch_fixtures import make_test_scope SERVICE_SPEC = importlib.util.find_spec('module_business.service.usage_service') CONTROLLER_SPEC = importlib.util.find_spec('module_business.controller.usage_controller') @@ -223,6 +225,17 @@ class TestUsageBootstrap(unittest.TestCase): @unittest.skipUnless(UsageService is not None, 'UsageService 尚未实现') class TestUsagePublicContract(unittest.TestCase): + def test_public_business_methods_require_explicit_test_scope(self) -> None: + for method_name in ( + 'get_usage_list_services', + 'usage_detail_services', + 'get_line_options_services', + 'scan_usage_pallet_services', + 'record_usage_services', + ): + with self.subTest(method=method_name): + self.assertIn('test_scope', inspect.signature(getattr(UsageService, method_name)).parameters) + def test_service_exposes_complete_usage_workflow(self) -> None: expected = { 'get_usage_list_services', @@ -235,11 +248,12 @@ class TestUsagePublicContract(unittest.TestCase): def test_record_signature_and_types_are_exact(self) -> None: signature = inspect.signature(UsageService.record_usage_services) - self.assertEqual(('query_db', 'page_object', 'current_user'), tuple(signature.parameters)) + self.assertEqual(('query_db', 'page_object', 'current_user', 'test_scope'), tuple(signature.parameters)) hints = get_type_hints(UsageService.record_usage_services) self.assertIs(AsyncSession, hints['query_db']) self.assertIs(UsageCreateModel, hints['page_object']) self.assertIs(CurrentUserModel, hints['current_user']) + self.assertIs(TestBatchScope, hints['test_scope']) self.assertIs(CrudResponseModel, hints['return']) def test_request_hash_is_stable_normalized_and_excludes_idempotency_key(self) -> None: @@ -319,9 +333,15 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[11, 9, 9])), patch.object(OperationDao, 'get_usage_list', new=get_list), ): - result = await UsageService.get_usage_list_services(self.db, query, self.user) + result = await UsageService.get_usage_list_services(self.db, query, self.user, TestBatchScope.formal()) - get_list.assert_awaited_once_with(self.db, query, True, site_ids=[9, 11]) + get_list.assert_awaited_once_with( + self.db, + query, + site_ids=[9, 11], + scope=TestBatchScope.formal(), + is_page=True, + ) self.assertEqual(1, result.total) self.assertIsInstance(result.rows[0], UsageModel) @@ -337,6 +357,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): self.db, UsagePageQueryModel(pageNum=2, pageSize=7), self.user, + TestBatchScope.formal(), is_page=is_page, ) @@ -360,6 +381,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): self.db, UsagePageQueryModel(siteId=10), self.user, + TestBatchScope.formal(), ) self.assertEqual('无权访问该客户现场', raised.exception.message) @@ -377,11 +399,18 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): self.db, query, make_user(1), + TestBatchScope.formal(), is_page=False, ) get_scope.assert_not_awaited() - get_list.assert_awaited_once_with(self.db, query, False, site_ids=None) + get_list.assert_awaited_once_with( + self.db, + query, + site_ids=None, + scope=TestBatchScope.formal(), + is_page=False, + ) self.assertEqual([], result) async def test_detail_is_scoped_and_hides_missing_but_admin_gets_precise_error(self) -> None: @@ -396,9 +425,14 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): patch.object(OperationDao, 'get_usage_by_id', new=get_detail), self.assertRaises(expected_exception) as raised, ): - await UsageService.usage_detail_services(self.db, 61, user) + await UsageService.usage_detail_services(self.db, 61, user, TestBatchScope.formal()) - get_detail.assert_awaited_once_with(self.db, 61, site_ids=expected_scope) + get_detail.assert_awaited_once_with( + self.db, + 61, + TestBatchScope.formal(), + site_ids=expected_scope, + ) self.assertEqual(expected_message, raised.exception.message) async def test_line_options_require_site_access_and_only_return_enabled_lines_for_site(self) -> None: @@ -415,14 +449,15 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())), patch.object(MasterDao, 'list_line', new=list_line), ): - result = await UsageService.get_line_options_services(self.db, 9, self.user) + result = await UsageService.get_line_options_services(self.db, 9, self.user, TestBatchScope.formal()) access.assert_awaited_once_with(self.db, self.user, 9) query = list_line.await_args.args[1] self.assertIsInstance(query, LinePageQueryModel) self.assertEqual(9, query.site_id) self.assertEqual('0', query.status) - self.assertFalse(list_line.await_args.args[2]) + self.assertEqual(TestBatchScope.formal(), list_line.await_args.args[2]) + self.assertFalse(list_line.await_args.args[3]) self.assertEqual([3, 6], [option.id for option in result]) self.assertTrue(all(isinstance(option, BusinessOptionModel) for option in result)) @@ -439,7 +474,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'list_line', new=list_line), self.assertRaises(PermissionException), ): - await UsageService.get_line_options_services(self.db, 9, self.user) + await UsageService.get_line_options_services(self.db, 9, self.user, TestBatchScope.formal()) get_site.assert_not_awaited() list_line.assert_not_awaited() @@ -466,10 +501,10 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock), patch.object(OperationDao, 'get_active_filling_for_update', new=filling_lock), ): - result = await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user) + result = await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, TestBatchScope.formal()) access.assert_awaited_once_with(self.db, self.user, 9) - get_filling.assert_awaited_once_with(self.db, 31) + get_filling.assert_awaited_once_with(self.db, 31, TestBatchScope.formal()) inventory_lock.assert_not_awaited() pallet_lock.assert_not_awaited() filling_lock.assert_not_awaited() @@ -489,7 +524,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), self.assertRaises(PermissionException) as missing, ): - await UsageService.scan_usage_pallet_services(self.db, 'missing', self.user) + await UsageService.scan_usage_pallet_services(self.db, 'missing', self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', missing.exception.message) with ( @@ -497,7 +532,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory(site_id=None))), self.assertRaises(PermissionException) as non_site, ): - await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user) + await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', non_site.exception.message) with ( @@ -510,7 +545,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): ), self.assertRaises(PermissionException) as unauthorized, ): - await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user) + await UsageService.scan_usage_pallet_services(self.db, 'qr-2', self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', unauthorized.exception.message) async def test_scan_admin_keeps_precise_missing_error(self) -> None: @@ -518,7 +553,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException) as raised, ): - await UsageService.scan_usage_pallet_services(self.db, 'missing', make_user(1)) + await UsageService.scan_usage_pallet_services(self.db, 'missing', make_user(1), TestBatchScope.formal()) self.assertEqual('托盘不存在', raised.exception.message) @@ -644,6 +679,7 @@ class TestUsageControllerResponse(unittest.IsolatedAsyncioTestCase): db = MagicMock(spec=AsyncSession) user = make_user() page = make_page() + test_scope = make_test_scope(9) service = AsyncMock( return_value=CrudResponseModel( is_success=True, @@ -661,9 +697,10 @@ class TestUsageControllerResponse(unittest.IsolatedAsyncioTestCase): add_usage=page, query_db=db, current_user=user, + test_scope=test_scope, ) - service.assert_awaited_once_with(db, page, user) + service.assert_awaited_once_with(db, page, user, test_scope) payload = json.loads(response.body) self.assertEqual('使用成功', payload['msg']) self.assertIn('data', payload) @@ -738,12 +775,13 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): async def read_line( db: AsyncSession, line_id: int, + scope: TestBatchScope, *, for_update: bool = False, ) -> BizProductionLine | None: if for_update: - return await line_lock(db, line_id, for_update=True) - return await preflight_line_read(db, line_id) + return await line_lock(db, line_id, scope, for_update=True) + return await preflight_line_read(db, line_id, scope) stack.enter_context(patch.object(AccessDao, 'list_user_site_ids', new=scope_read)) stack.enter_context(patch.object(AccessService, 'assert_site_access', new=site_access)) @@ -884,7 +922,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): patch('module_business.service.usage_service.datetime') as mocked_datetime, ): mocked_datetime.now.return_value = self.fixed_time - result = await UsageService.record_usage_services(self.db, self.page, self.user) + result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual( [ @@ -987,6 +1025,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): self.db, make_page(used_packages=3), self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) @@ -1028,6 +1067,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): self.db, make_page(used_packages=2), self.user, + TestBatchScope.formal(), ) self.assertTrue(result.is_success) self.assertEqual(4, same_inventory.current_line_id) @@ -1040,7 +1080,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): ) stack, mocks = self._patch_record_path(inventory=other_inventory) with stack, self.assertRaises(ServiceException) as raised: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertIn('已绑定其他产线', raised.exception.message) mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() @@ -1060,7 +1100,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): mocks['preflight_line_read'].return_value = None mocks['line_lock'].return_value = None with stack, self.assertRaises(ServiceException) as raised: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(message, raised.exception.message) mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() @@ -1091,7 +1131,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): self.db.reset_mock() stack, mocks = self._patch_record_path(inventory=inventory) with stack, self.assertRaises(ServiceException) as raised: - await UsageService.record_usage_services(self.db, page, self.user) + await UsageService.record_usage_services(self.db, page, self.user, TestBatchScope.formal()) self.assertEqual(message, raised.exception.message) mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() @@ -1102,7 +1142,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): access = AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场')) stack, mocks = self._patch_record_path(access=access) with stack, self.assertRaises(PermissionException) as raised: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', raised.exception.message) mocks['inventory_lock'].assert_awaited_once() @@ -1127,10 +1167,15 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): access = AsyncMock(side_effect=access_check) stack, mocks = self._patch_record_path(site=make_site(status='1'), access=access) with stack, self.assertRaises(PermissionException) as raised: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', raised.exception.message) - mocks['site_lock'].assert_awaited_once_with(self.db, 9, for_update=True) + mocks['site_lock'].assert_awaited_once_with( + self.db, + 9, + TestBatchScope.formal(), + for_update=True, + ) access.assert_awaited_with(self.db, self.user, 9, for_update=True) mocks['line_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() @@ -1144,7 +1189,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): if site is None: mocks['site_lock'].return_value = None with stack, self.assertRaises(ServiceException) as raised: - await UsageService.record_usage_services(self.db, self.page, make_user(1)) + await UsageService.record_usage_services(self.db, self.page, make_user(1), TestBatchScope.formal()) self.assertEqual(message, raised.exception.message) mocks['add_usage'].assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) @@ -1162,7 +1207,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): self.db.reset_mock() stack, mocks = self._patch_record_path(**overrides) with stack, self.assertRaises(ServiceException): - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) mocks['add_usage'].assert_not_awaited() mocks['update_filling'].assert_not_awaited() mocks['add_event'].assert_not_awaited() @@ -1173,7 +1218,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): inventory = make_inventory(version=-1) stack, mocks = self._patch_record_path(inventory=inventory) with stack, self.assertRaises(ServiceException) as raised: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('托盘库存版本无效', raised.exception.message) mocks['add_usage'].assert_not_awaited() @@ -1188,7 +1233,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), self.assertRaises(PermissionException) as normal_missing, ): - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', normal_missing.exception.message) self.db.rollback.assert_awaited_once() @@ -1199,14 +1244,14 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException) as admin_missing, ): - await UsageService.record_usage_services(self.db, self.page, make_user(1)) + await UsageService.record_usage_services(self.db, self.page, make_user(1), TestBatchScope.formal()) self.assertEqual('托盘不存在', admin_missing.exception.message) self.db.rollback.assert_awaited_once() self.db.reset_mock() stack, mocks = self._patch_record_path(inventory=make_inventory(site_id=None)) with stack, self.assertRaises(PermissionException) as non_site: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('无权访问该托盘', non_site.exception.message) mocks['pallet_lock'].assert_not_awaited() self.assertEqual(2, self.db.rollback.await_count) @@ -1329,14 +1374,15 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock), patch.object(OperationDao, 'add_usage_record', new=AsyncMock()) as add_usage, ): - result = await UsageService.record_usage_services(self.db, self.page, self.user) + result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) get_key.assert_awaited_once_with( self.db, 'usage-key', + TestBatchScope.formal(), site_ids=[9], ) - site_lock.assert_awaited_once_with(self.db, 9, for_update=True) + site_lock.assert_awaited_once_with(self.db, 9, TestBatchScope.formal(), for_update=True) access.assert_awaited_once_with(self.db, self.user, 9, for_update=True) inventory_lock.assert_not_awaited() add_usage.assert_not_awaited() @@ -1367,10 +1413,15 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())), patch.object(AccessService, 'assert_site_access', new=AsyncMock()), ): - result = await UsageService.record_usage_services(self.db, self.page, user) + result = await UsageService.record_usage_services(self.db, self.page, user, TestBatchScope.formal()) - line_read.assert_awaited_once_with(self.db, 4) - get_key.assert_awaited_once_with(self.db, 'usage-key', site_ids=[9]) + line_read.assert_awaited_once_with(self.db, 4, TestBatchScope.formal()) + get_key.assert_awaited_once_with( + self.db, + 'usage-key', + TestBatchScope.formal(), + site_ids=[9], + ) self.assertEqual(61, result.result.usage_id) if user.user and user.user.admin: scope_read.assert_not_awaited() @@ -1384,7 +1435,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock()) as inventory_lock, self.assertRaises(ServiceException) as raised, ): - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertIn('幂等键', raised.exception.message) inventory_lock.assert_not_awaited() @@ -1401,7 +1452,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): patch.object(AccessService, 'assert_site_access', new=access), self.assertRaises(PermissionException), ): - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) access.assert_awaited_once_with(self.db, self.user, 9, for_update=True) self.db.commit.assert_not_awaited() @@ -1460,6 +1511,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): self.db, self.page, self.user, + TestBatchScope.formal(), ) except ServiceException as exc: self.fail(f'精确并发重试被旧库存状态提前拒绝:{exc.message}') @@ -1495,7 +1547,12 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): mocks['filling_lock'].assert_awaited_once() else: mocks['filling_lock'].assert_not_awaited() - mocks['site_lock'].assert_awaited_once_with(self.db, 9, for_update=True) + mocks['site_lock'].assert_awaited_once_with( + self.db, + 9, + TestBatchScope.formal(), + for_update=True, + ) mocks['line_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() mocks['add_event'].assert_not_awaited() @@ -1541,7 +1598,9 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): ) with stack: try: - result = await UsageService.record_usage_services(self.db, self.page, self.user) + result = await UsageService.record_usage_services( + self.db, self.page, self.user, TestBatchScope.formal() + ) except ServiceException as exc: self.fail(f'锁等待期间新增授权后未使用 fresh scope:{exc.message}') @@ -1590,7 +1649,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): ) with stack: try: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) except PermissionException as exc: self.assertEqual('无权访问该托盘', exc.message) else: @@ -1653,13 +1712,13 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): self.db.rollback.side_effect = rollback with stack, self.assertRaises(ServiceException) as raised: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual('托盘二维码与库存不一致', raised.exception.message) self.assertTrue(scanned.expired) self.assertEqual(1, scanned.pallet_id_reads) - mocks['inventory_lock'].assert_awaited_once_with(self.db, 2) - mocks['pallet_lock'].assert_awaited_once_with(self.db, [2]) + mocks['inventory_lock'].assert_awaited_once_with(self.db, 2, TestBatchScope.formal()) + mocks['pallet_lock'].assert_awaited_once_with(self.db, [2], TestBatchScope.formal()) self.assertEqual(2, scope.await_count) self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}], @@ -1688,7 +1747,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): access = mocks['access'] with stack: try: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) except PermissionException as exc: self.assertEqual('无权访问该使用记录', exc.message) except ServiceException as exc: @@ -1701,7 +1760,12 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): [call.kwargs for call in key_read.await_args_list], ) self.assertEqual(2, mocks['scope_read'].await_count) - mocks['site_lock'].assert_awaited_once_with(self.db, 9, for_update=True) + mocks['site_lock'].assert_awaited_once_with( + self.db, + 9, + TestBatchScope.formal(), + for_update=True, + ) self.assertEqual([False, True], [call.kwargs.get('for_update', False) for call in access.await_args_list]) mocks['line_lock'].assert_not_awaited() mocks['add_usage'].assert_not_awaited() @@ -1734,7 +1798,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): key_read = mocks['key_read'] with stack: try: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) except ServiceException as exc: self.assertEqual( '使用失败,幂等键已被其他请求使用', @@ -1768,7 +1832,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): add_usage=add_usage, ) 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, TestBatchScope.formal()) self.assertIsInstance(result.result, UsageSuccessResponseModel) self.assertEqual( @@ -1800,7 +1864,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): add_usage=AsyncMock(side_effect=integrity_error()), ) with stack, self.assertRaises(ServiceException) as raised: - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertTrue('幂等' in raised.exception.message or '冲突' in raised.exception.message) self.assertEqual( [{'site_ids': [9]}, {'site_ids': [9]}, {'site_ids': [9]}], @@ -1818,7 +1882,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): add_usage=AsyncMock(side_effect=integrity_error()), ) with stack, self.assertRaises(PermissionException): - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(3, scope.await_count) self.assertEqual(2, key_read.await_count) @@ -1835,7 +1899,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): add_event=AsyncMock(side_effect=RuntimeError('event failed')), ) with stack, self.assertRaises(RuntimeError): - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.assertEqual(2, self.db.rollback.await_count) self.db.commit.assert_not_awaited() @@ -1845,7 +1909,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase): patch.object(UsageService, '_build_request_hash', side_effect=RuntimeError('hash failed')), self.assertRaises(RuntimeError), ): - await UsageService.record_usage_services(self.db, self.page, self.user) + await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal()) self.db.rollback.assert_awaited_once() self.db.commit.assert_not_awaited()