From b6f606d9f27c69b7818c7c31aac1a4dd0331afd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Tue, 22 Sep 2026 18:16:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=EF=BC=9A=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E4=B8=BB=E6=95=B0=E6=8D=AE=E5=B9=B6=E5=B0=81=E9=97=AD=E5=8D=95?= =?UTF-8?q?=E6=9D=A1=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/customer_controller.py | 14 +- .../controller/line_controller.py | 14 +- .../controller/pallet_controller.py | 17 +- .../controller/site_controller.py | 20 +- .../controller/strain_controller.py | 14 +- .../controller/warehouse_controller.py | 25 +- .../module_business/dao/master_dao.py | 357 +++----- .../module_business/entity/vo/master_vo.py | 26 - .../module_business/service/master_service.py | 273 +++--- .../module_business/service/pallet_service.py | 161 ++-- .../module_business/test_master_service.py | 749 +++++++++------- .../module_business/test_pallet_service.py | 817 ++++++++++-------- .../tests/module_business/test_vo_contract.py | 26 +- 13 files changed, 1382 insertions(+), 1131 deletions(-) diff --git a/shierfeng-fastapi-backend/module_business/controller/customer_controller.py b/shierfeng-fastapi-backend/module_business/controller/customer_controller.py index 1ff3d3f..1407c7e 100644 --- a/shierfeng-fastapi-backend/module_business/controller/customer_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/customer_controller.py @@ -12,6 +12,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.master_vo import CustomerModel, CustomerPageQueryModel, DeleteCustomerModel from module_business.service.master_service import MasterService from utils.response_util import ResponseUtil @@ -32,8 +34,9 @@ async def get_master_customer_list( request: Request, customer_page_query: Annotated[CustomerPageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - customer_page_query_result = await MasterService.list_customer(query_db, customer_page_query, is_page=True) + customer_page_query_result = await MasterService.list_customer(query_db, customer_page_query, scope, is_page=True) return ResponseUtil.success(model_content=customer_page_query_result) @@ -49,8 +52,9 @@ async def query_detail_master_customer( request: Request, customer_id: Annotated[int, Path(gt=0, description='客户ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - customer_detail_result = await MasterService.detail_customer(query_db, customer_id) + customer_detail_result = await MasterService.detail_customer(query_db, customer_id, scope) return ResponseUtil.success(data=customer_detail_result) @@ -68,13 +72,14 @@ async def add_master_customer( add_customer: CustomerModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: current_time = datetime.now() add_customer.create_by = current_user.user.user_name add_customer.create_time = current_time add_customer.update_by = current_user.user.user_name add_customer.update_time = current_time - add_customer_result = await MasterService.add_customer(query_db, add_customer) + add_customer_result = await MasterService.add_customer(query_db, add_customer, current_user, scope) return ResponseUtil.success(msg=add_customer_result.message) @@ -92,10 +97,11 @@ async def edit_master_customer( edit_customer: CustomerModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: edit_customer.update_by = current_user.user.user_name edit_customer.update_time = datetime.now() - edit_customer_result = await MasterService.edit_customer(query_db, edit_customer) + edit_customer_result = await MasterService.edit_customer(query_db, edit_customer, current_user, scope) return ResponseUtil.success(msg=edit_customer_result.message) diff --git a/shierfeng-fastapi-backend/module_business/controller/line_controller.py b/shierfeng-fastapi-backend/module_business/controller/line_controller.py index 04cf487..f32d592 100644 --- a/shierfeng-fastapi-backend/module_business/controller/line_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/line_controller.py @@ -12,6 +12,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.master_vo import DeleteLineModel, LineModel, LinePageQueryModel from module_business.service.master_service import MasterService from utils.response_util import ResponseUtil @@ -30,8 +32,9 @@ async def get_master_line_list( request: Request, line_page_query: Annotated[LinePageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - line_page_query_result = await MasterService.list_line(query_db, line_page_query, is_page=True) + line_page_query_result = await MasterService.list_line(query_db, line_page_query, scope, is_page=True) return ResponseUtil.success(model_content=line_page_query_result) @@ -47,8 +50,9 @@ async def query_detail_master_line( request: Request, line_id: Annotated[int, Path(gt=0, description='生产线ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - line_detail_result = await MasterService.detail_line(query_db, line_id) + line_detail_result = await MasterService.detail_line(query_db, line_id, scope) return ResponseUtil.success(data=line_detail_result) @@ -66,13 +70,14 @@ async def add_master_line( add_line: LineModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: current_time = datetime.now() add_line.create_by = current_user.user.user_name add_line.create_time = current_time add_line.update_by = current_user.user.user_name add_line.update_time = current_time - add_line_result = await MasterService.add_line(query_db, add_line) + add_line_result = await MasterService.add_line(query_db, add_line, current_user, scope) return ResponseUtil.success(msg=add_line_result.message) @@ -90,10 +95,11 @@ async def edit_master_line( edit_line: LineModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: edit_line.update_by = current_user.user.user_name edit_line.update_time = datetime.now() - edit_line_result = await MasterService.edit_line(query_db, edit_line) + edit_line_result = await MasterService.edit_line(query_db, edit_line, current_user, scope) return ResponseUtil.success(msg=edit_line_result.message) diff --git a/shierfeng-fastapi-backend/module_business/controller/pallet_controller.py b/shierfeng-fastapi-backend/module_business/controller/pallet_controller.py index 6418d54..e0504a4 100644 --- a/shierfeng-fastapi-backend/module_business/controller/pallet_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/pallet_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.master_vo import ( DeletePalletModel, PalletCreateModel, @@ -37,8 +39,9 @@ async def get_master_pallet_list( request: Request, pallet_page_query: Annotated[PalletPageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await PalletService.list_pallet(query_db, pallet_page_query, is_page=True) + result = await PalletService.list_pallet(query_db, pallet_page_query, scope, is_page=True) return ResponseUtil.success(model_content=result) @@ -54,8 +57,9 @@ async def query_master_pallet_qrcode( request: Request, pallet_id: Annotated[int, Path(gt=0, description='托盘ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - png = await PalletService.get_pallet_qrcode(query_db, pallet_id) + png = await PalletService.get_pallet_qrcode(query_db, pallet_id, scope) return Response(content=png, media_type='image/png') @@ -70,8 +74,9 @@ async def query_detail_master_pallet( request: Request, pallet_id: Annotated[int, Path(gt=0, description='托盘ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await PalletService.detail_pallet(query_db, pallet_id) + result = await PalletService.detail_pallet(query_db, pallet_id, scope) return ResponseUtil.success(data=result) @@ -88,8 +93,9 @@ async def add_master_pallet( add_pallet: PalletCreateModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await PalletService.add_pallet(query_db, add_pallet, current_user.user.user_name) + result = await PalletService.add_pallet(query_db, add_pallet, current_user, scope) return ResponseUtil.success(msg=result.message) @@ -106,8 +112,9 @@ async def edit_master_pallet( edit_pallet: PalletEditModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await PalletService.edit_pallet(query_db, edit_pallet, current_user.user.user_name) + result = await PalletService.edit_pallet(query_db, edit_pallet, current_user, scope) return ResponseUtil.success(msg=result.message) diff --git a/shierfeng-fastapi-backend/module_business/controller/site_controller.py b/shierfeng-fastapi-backend/module_business/controller/site_controller.py index 8bee104..0d402d8 100644 --- a/shierfeng-fastapi-backend/module_business/controller/site_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/site_controller.py @@ -12,6 +12,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.master_vo import ( BusinessUserOptionModel, DeleteSiteModel, @@ -45,8 +47,9 @@ async def get_master_site_users( request: Request, site_id: Annotated[int, Path(gt=0, description='客户现场ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - users = await PalletService.list_site_users(query_db, site_id) + users = await PalletService.list_site_users(query_db, site_id, scope) return ResponseUtil.success(data=users) @@ -64,8 +67,9 @@ async def assign_master_site_users( assignment: UserAssignModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, ASSIGN_CURRENT_USER()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await PalletService.assign_site_users(query_db, site_id, assignment) + result = await PalletService.assign_site_users(query_db, site_id, assignment, current_user, scope) return ResponseUtil.success(msg=result.message) @@ -80,8 +84,9 @@ async def get_master_site_list( request: Request, site_page_query: Annotated[SitePageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - site_page_query_result = await MasterService.list_site(query_db, site_page_query, is_page=True) + site_page_query_result = await MasterService.list_site(query_db, site_page_query, scope, is_page=True) return ResponseUtil.success(model_content=site_page_query_result) @@ -97,8 +102,9 @@ async def query_detail_master_site( request: Request, site_id: Annotated[int, Path(gt=0, description='客户现场ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - site_detail_result = await MasterService.detail_site(query_db, site_id) + site_detail_result = await MasterService.detail_site(query_db, site_id, scope) return ResponseUtil.success(data=site_detail_result) @@ -116,13 +122,14 @@ async def add_master_site( add_site: SiteModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: current_time = datetime.now() add_site.create_by = current_user.user.user_name add_site.create_time = current_time add_site.update_by = current_user.user.user_name add_site.update_time = current_time - add_site_result = await MasterService.add_site(query_db, add_site) + add_site_result = await MasterService.add_site(query_db, add_site, current_user, scope) return ResponseUtil.success(msg=add_site_result.message) @@ -140,10 +147,11 @@ async def edit_master_site( edit_site: SiteModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: edit_site.update_by = current_user.user.user_name edit_site.update_time = datetime.now() - edit_site_result = await MasterService.edit_site(query_db, edit_site) + edit_site_result = await MasterService.edit_site(query_db, edit_site, current_user, scope) return ResponseUtil.success(msg=edit_site_result.message) diff --git a/shierfeng-fastapi-backend/module_business/controller/strain_controller.py b/shierfeng-fastapi-backend/module_business/controller/strain_controller.py index 45183ea..97c03a6 100644 --- a/shierfeng-fastapi-backend/module_business/controller/strain_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/strain_controller.py @@ -12,6 +12,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.master_vo import DeleteStrainModel, StrainModel, StrainPageQueryModel from module_business.service.master_service import MasterService from utils.response_util import ResponseUtil @@ -32,8 +34,9 @@ async def get_master_strain_list( request: Request, strain_page_query: Annotated[StrainPageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - strain_page_query_result = await MasterService.list_strain(query_db, strain_page_query, is_page=True) + strain_page_query_result = await MasterService.list_strain(query_db, strain_page_query, scope, is_page=True) return ResponseUtil.success(model_content=strain_page_query_result) @@ -49,8 +52,9 @@ async def query_detail_master_strain( request: Request, strain_id: Annotated[int, Path(gt=0, description='菌种ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - strain_detail_result = await MasterService.detail_strain(query_db, strain_id) + strain_detail_result = await MasterService.detail_strain(query_db, strain_id, scope) return ResponseUtil.success(data=strain_detail_result) @@ -68,13 +72,14 @@ async def add_master_strain( add_strain: StrainModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: current_time = datetime.now() add_strain.create_by = current_user.user.user_name add_strain.create_time = current_time add_strain.update_by = current_user.user.user_name add_strain.update_time = current_time - add_strain_result = await MasterService.add_strain(query_db, add_strain) + add_strain_result = await MasterService.add_strain(query_db, add_strain, current_user, scope) return ResponseUtil.success(msg=add_strain_result.message) @@ -92,10 +97,11 @@ async def edit_master_strain( edit_strain: StrainModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: edit_strain.update_by = current_user.user.user_name edit_strain.update_time = datetime.now() - edit_strain_result = await MasterService.edit_strain(query_db, edit_strain) + edit_strain_result = await MasterService.edit_strain(query_db, edit_strain, current_user, scope) return ResponseUtil.success(msg=edit_strain_result.message) diff --git a/shierfeng-fastapi-backend/module_business/controller/warehouse_controller.py b/shierfeng-fastapi-backend/module_business/controller/warehouse_controller.py index 511d2be..fcab999 100644 --- a/shierfeng-fastapi-backend/module_business/controller/warehouse_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/warehouse_controller.py @@ -12,6 +12,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.master_vo import ( BusinessUserOptionModel, DeleteWarehouseModel, @@ -45,8 +47,9 @@ async def get_master_warehouse_users( request: Request, warehouse_id: Annotated[int, Path(gt=0, description='仓库ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - users = await PalletService.list_warehouse_users(query_db, warehouse_id) + users = await PalletService.list_warehouse_users(query_db, warehouse_id, scope) return ResponseUtil.success(data=users) @@ -64,8 +67,9 @@ async def assign_master_warehouse_users( assignment: UserAssignModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, ASSIGN_CURRENT_USER()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: - result = await PalletService.assign_warehouse_users(query_db, warehouse_id, assignment) + result = await PalletService.assign_warehouse_users(query_db, warehouse_id, assignment, current_user, scope) return ResponseUtil.success(msg=result.message) @@ -80,8 +84,14 @@ async def get_master_warehouse_list( request: Request, warehouse_page_query: Annotated[WarehousePageQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - warehouse_page_query_result = await MasterService.list_warehouse(query_db, warehouse_page_query, is_page=True) + warehouse_page_query_result = await MasterService.list_warehouse( + query_db, + warehouse_page_query, + scope, + is_page=True, + ) return ResponseUtil.success(model_content=warehouse_page_query_result) @@ -97,8 +107,9 @@ async def query_detail_master_warehouse( request: Request, warehouse_id: Annotated[int, Path(gt=0, description='仓库ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - warehouse_detail_result = await MasterService.detail_warehouse(query_db, warehouse_id) + warehouse_detail_result = await MasterService.detail_warehouse(query_db, warehouse_id, scope) return ResponseUtil.success(data=warehouse_detail_result) @@ -116,13 +127,14 @@ async def add_master_warehouse( add_warehouse: WarehouseModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: current_time = datetime.now() add_warehouse.create_by = current_user.user.user_name add_warehouse.create_time = current_time add_warehouse.update_by = current_user.user.user_name add_warehouse.update_time = current_time - add_warehouse_result = await MasterService.add_warehouse(query_db, add_warehouse) + add_warehouse_result = await MasterService.add_warehouse(query_db, add_warehouse, current_user, scope) return ResponseUtil.success(msg=add_warehouse_result.message) @@ -140,10 +152,11 @@ async def edit_master_warehouse( edit_warehouse: WarehouseModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], ) -> Response: edit_warehouse.update_by = current_user.user.user_name edit_warehouse.update_time = datetime.now() - edit_warehouse_result = await MasterService.edit_warehouse(query_db, edit_warehouse) + edit_warehouse_result = await MasterService.edit_warehouse(query_db, edit_warehouse, current_user, scope) return ResponseUtil.success(msg=edit_warehouse_result.message) diff --git a/shierfeng-fastapi-backend/module_business/dao/master_dao.py b/shierfeng-fastapi-backend/module_business/dao/master_dao.py index 24adefd..79fe247 100644 --- a/shierfeng-fastapi-backend/module_business/dao/master_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/master_dao.py @@ -1,14 +1,14 @@ from datetime import datetime -from typing import Any, Literal +from typing import Any from pydantic import BaseModel -from sqlalchemy import ColumnElement, Select, and_, delete, func, or_, select, update +from sqlalchemy import ColumnElement, Select, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import InstrumentedAttribute from common.vo import PageModel from module_admin.entity.do.user_do import SysUser -from module_business.domain.enums import PalletFlowState +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition from module_business.entity.do.master_do import ( BizCustomer, BizCustomerSite, @@ -19,18 +19,7 @@ from module_business.entity.do.master_do import ( BizUserWarehouse, BizWarehouse, ) -from module_business.entity.do.operation_do import ( - BizFillingRecord, - BizInboundItem, - BizInboundOrder, - BizPalletEvent, - BizPalletInventory, - BizReturnItem, - BizReturnOrder, - BizShipmentItem, - BizShipmentOrder, - BizUsageRecord, -) +from module_business.entity.do.operation_do import BizPalletInventory from module_business.entity.vo.master_vo import ( CustomerModel, CustomerPageQueryModel, @@ -47,54 +36,6 @@ from module_business.entity.vo.master_vo import ( ) from utils.page_util import PageUtil -MasterResource = Literal['strain', 'customer', 'site', 'line', 'warehouse'] - - -_REFERENCE_COLUMNS: dict[MasterResource, tuple[tuple[InstrumentedAttribute, ...], ...]] = { - 'strain': ((BizFillingRecord.strain_id,),), - 'customer': ((BizCustomerSite.customer_id,),), - 'site': ( - (BizProductionLine.site_id,), - (BizUserSite.site_id,), - (BizShipmentOrder.target_site_id,), - (BizUsageRecord.site_id,), - (BizReturnOrder.source_site_id,), - (BizPalletInventory.site_id,), - (BizPalletEvent.before_site_id, BizPalletEvent.after_site_id), - ), - 'line': ( - (BizUsageRecord.line_id,), - (BizPalletInventory.current_line_id,), - (BizPalletEvent.before_line_id, BizPalletEvent.after_line_id), - ), - 'warehouse': ( - (BizUserWarehouse.warehouse_id,), - (BizInboundOrder.warehouse_id,), - (BizFillingRecord.warehouse_id,), - (BizShipmentOrder.source_warehouse_id,), - (BizReturnOrder.target_warehouse_id,), - (BizPalletInventory.warehouse_id,), - (BizPalletEvent.before_warehouse_id, BizPalletEvent.after_warehouse_id), - ), -} - -_MASTER_ID_COLUMNS: dict[MasterResource, InstrumentedAttribute] = { - 'strain': BizStrain.strain_id, - 'customer': BizCustomer.customer_id, - 'site': BizCustomerSite.site_id, - 'line': BizProductionLine.line_id, - 'warehouse': BizWarehouse.warehouse_id, -} - -_PALLET_REMOVAL_BLOCKING_COLUMNS: tuple[InstrumentedAttribute, ...] = ( - BizInboundItem.pallet_id, - BizFillingRecord.pallet_id, - BizShipmentItem.pallet_id, - BizUsageRecord.pallet_id, - BizReturnItem.pallet_id, - BizPalletEvent.pallet_id, -) - class MasterDao: """菌种、客户、现场、产线和仓库主数据数据库操作层。""" @@ -120,10 +61,11 @@ class MasterDao: model: type[Any], id_column: InstrumentedAttribute, resource_id: int, + scope_predicate: ColumnElement[bool], *, for_update: bool = False, ) -> Any | None: - statement = select(model).where(id_column == resource_id) + statement = select(model).where(id_column == resource_id, scope_predicate) if for_update: statement = statement.with_for_update().execution_options(populate_existing=True) result = await db.execute(statement) @@ -146,8 +88,10 @@ class MasterDao: model: type[Any], id_field: str, page_object: BaseModel, + test_batch_id: int | None, ) -> Any: - payload = page_object.model_dump(exclude={id_field, 'can_remove'}) + payload = page_object.model_dump(exclude={id_field, 'can_remove', 'test_batch_id'}) + payload['test_batch_id'] = test_batch_id record = model(**payload) db.add(record) await db.flush() @@ -160,63 +104,31 @@ class MasterDao: id_column: InstrumentedAttribute, id_field: str, page_object: BaseModel, + scope: TestBatchScope, ) -> None: payload = page_object.model_dump( exclude_unset=True, - exclude={'can_remove', 'create_by', 'create_time'}, + exclude={'can_remove', 'create_by', 'create_time', 'test_batch_id'}, ) resource_id = payload.pop(id_field) - await db.execute(update(model).where(id_column == resource_id).values(**payload)) - - @staticmethod - async def _delete( - db: AsyncSession, - model: type[Any], - id_column: InstrumentedAttribute, - resource_id: int, - ) -> None: - await db.execute(delete(model).where(id_column == resource_id)) - - @classmethod - async def get_master_removability( - cls, - db: AsyncSession, - resource_type: MasterResource, - resource_ids: list[int], - ) -> dict[int, bool]: - """按删除约束批量判断五类主数据是否允许删除。""" - removability = dict.fromkeys(resource_ids, False) - if not resource_ids: - return removability - - id_column = _MASTER_ID_COLUMNS.get(resource_type) - references = _REFERENCE_COLUMNS.get(resource_type) - if id_column is None or references is None: - raise ValueError(f'不支持的主数据类型:{resource_type}') - - has_no_references = [] - for columns in references: - predicates = [column == id_column for column in columns] - predicate = predicates[0] if len(predicates) == 1 else or_(*predicates) - has_no_references.append(~select(columns[0]).where(predicate).exists()) - - statement = select( - id_column, - and_(*has_no_references).label('can_remove'), - ).where(id_column.in_(resource_ids)) - result = await db.execute(statement) - for resource_id, can_remove in result.all(): - removability[resource_id] = bool(can_remove) - return removability + await db.execute( + update(model) + .where( + id_column == resource_id, + batch_scope_condition(model.test_batch_id, scope), + ) + .values(**payload) + ) @classmethod async def list_strain( cls, db: AsyncSession, query_object: StrainPageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[dict[str, Any]]: - conditions = [] + conditions = [batch_scope_condition(BizStrain.test_batch_id, scope)] if query_object.strain_code: conditions.append(BizStrain.strain_code.like(f'%{query_object.strain_code}%')) if query_object.strain_name: @@ -231,6 +143,7 @@ class MasterDao: cls, db: AsyncSession, strain_id: int, + scope: TestBatchScope, *, for_update: bool = False, ) -> BizStrain | None: @@ -239,6 +152,7 @@ class MasterDao: BizStrain, BizStrain.strain_id, strain_id, + batch_scope_condition(BizStrain.test_batch_id, scope), for_update=for_update, ) @@ -247,25 +161,22 @@ class MasterDao: return await cls._find(db, BizStrain, BizStrain.strain_code, strain_code) @classmethod - async def add_strain(cls, db: AsyncSession, strain: StrainModel) -> BizStrain: - return await cls._add(db, BizStrain, 'strain_id', strain) + async def add_strain(cls, db: AsyncSession, strain: StrainModel, test_batch_id: int | None) -> BizStrain: + return await cls._add(db, BizStrain, 'strain_id', strain, test_batch_id) @classmethod - async def update_strain(cls, db: AsyncSession, strain: StrainModel) -> None: - await cls._update(db, BizStrain, BizStrain.strain_id, 'strain_id', strain) - - @classmethod - async def delete_strain(cls, db: AsyncSession, strain_id: int) -> None: - await cls._delete(db, BizStrain, BizStrain.strain_id, strain_id) + async def update_strain(cls, db: AsyncSession, strain: StrainModel, scope: TestBatchScope) -> None: + await cls._update(db, BizStrain, BizStrain.strain_id, 'strain_id', strain, scope) @classmethod async def list_customer( cls, db: AsyncSession, query_object: CustomerPageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[dict[str, Any]]: - conditions = [] + conditions = [batch_scope_condition(BizCustomer.test_batch_id, scope)] if query_object.customer_code: conditions.append(BizCustomer.customer_code.like(f'%{query_object.customer_code}%')) if query_object.customer_name: @@ -276,33 +187,44 @@ class MasterDao: return await cls._paginate(db, statement, query_object, is_page) @classmethod - async def get_customer(cls, db: AsyncSession, customer_id: int) -> BizCustomer | None: - return await cls._get(db, BizCustomer, BizCustomer.customer_id, customer_id) + async def get_customer( + cls, + db: AsyncSession, + customer_id: int, + scope: TestBatchScope, + *, + for_update: bool = False, + ) -> BizCustomer | None: + return await cls._get( + db, + BizCustomer, + BizCustomer.customer_id, + customer_id, + batch_scope_condition(BizCustomer.test_batch_id, scope), + for_update=for_update, + ) @classmethod async def find_customer_by_code(cls, db: AsyncSession, customer_code: str) -> BizCustomer | None: return await cls._find(db, BizCustomer, BizCustomer.customer_code, customer_code) @classmethod - async def add_customer(cls, db: AsyncSession, customer: CustomerModel) -> BizCustomer: - return await cls._add(db, BizCustomer, 'customer_id', customer) + async def add_customer(cls, db: AsyncSession, customer: CustomerModel, test_batch_id: int | None) -> BizCustomer: + return await cls._add(db, BizCustomer, 'customer_id', customer, test_batch_id) @classmethod - async def update_customer(cls, db: AsyncSession, customer: CustomerModel) -> None: - await cls._update(db, BizCustomer, BizCustomer.customer_id, 'customer_id', customer) - - @classmethod - async def delete_customer(cls, db: AsyncSession, customer_id: int) -> None: - await cls._delete(db, BizCustomer, BizCustomer.customer_id, customer_id) + async def update_customer(cls, db: AsyncSession, customer: CustomerModel, scope: TestBatchScope) -> None: + await cls._update(db, BizCustomer, BizCustomer.customer_id, 'customer_id', customer, scope) @classmethod async def list_site( cls, db: AsyncSession, query_object: SitePageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[dict[str, Any]]: - conditions = [] + conditions = [batch_scope_condition(BizCustomerSite.test_batch_id, scope)] if query_object.customer_id: conditions.append(BizCustomerSite.customer_id == query_object.customer_id) if query_object.site_code: @@ -319,6 +241,7 @@ class MasterDao: cls, db: AsyncSession, site_id: int, + scope: TestBatchScope, *, for_update: bool = False, ) -> BizCustomerSite | None: @@ -327,6 +250,7 @@ class MasterDao: BizCustomerSite, BizCustomerSite.site_id, site_id, + batch_scope_condition(BizCustomerSite.test_batch_id, scope), for_update=for_update, ) @@ -346,25 +270,22 @@ class MasterDao: ) @classmethod - async def add_site(cls, db: AsyncSession, site: SiteModel) -> BizCustomerSite: - return await cls._add(db, BizCustomerSite, 'site_id', site) + async def add_site(cls, db: AsyncSession, site: SiteModel, test_batch_id: int | None) -> BizCustomerSite: + return await cls._add(db, BizCustomerSite, 'site_id', site, test_batch_id) @classmethod - async def update_site(cls, db: AsyncSession, site: SiteModel) -> None: - await cls._update(db, BizCustomerSite, BizCustomerSite.site_id, 'site_id', site) - - @classmethod - async def delete_site(cls, db: AsyncSession, site_id: int) -> None: - await cls._delete(db, BizCustomerSite, BizCustomerSite.site_id, site_id) + async def update_site(cls, db: AsyncSession, site: SiteModel, scope: TestBatchScope) -> None: + await cls._update(db, BizCustomerSite, BizCustomerSite.site_id, 'site_id', site, scope) @classmethod async def list_line( cls, db: AsyncSession, query_object: LinePageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[dict[str, Any]]: - conditions = [] + conditions = [batch_scope_condition(BizProductionLine.test_batch_id, scope)] if query_object.site_id: conditions.append(BizProductionLine.site_id == query_object.site_id) if query_object.line_code: @@ -381,6 +302,7 @@ class MasterDao: cls, db: AsyncSession, line_id: int, + scope: TestBatchScope, *, for_update: bool = False, ) -> BizProductionLine | None: @@ -389,6 +311,7 @@ class MasterDao: BizProductionLine, BizProductionLine.line_id, line_id, + batch_scope_condition(BizProductionLine.test_batch_id, scope), for_update=for_update, ) @@ -408,25 +331,22 @@ class MasterDao: ) @classmethod - async def add_line(cls, db: AsyncSession, line: LineModel) -> BizProductionLine: - return await cls._add(db, BizProductionLine, 'line_id', line) + async def add_line(cls, db: AsyncSession, line: LineModel, test_batch_id: int | None) -> BizProductionLine: + return await cls._add(db, BizProductionLine, 'line_id', line, test_batch_id) @classmethod - async def update_line(cls, db: AsyncSession, line: LineModel) -> None: - await cls._update(db, BizProductionLine, BizProductionLine.line_id, 'line_id', line) - - @classmethod - async def delete_line(cls, db: AsyncSession, line_id: int) -> None: - await cls._delete(db, BizProductionLine, BizProductionLine.line_id, line_id) + async def update_line(cls, db: AsyncSession, line: LineModel, scope: TestBatchScope) -> None: + await cls._update(db, BizProductionLine, BizProductionLine.line_id, 'line_id', line, scope) @classmethod async def list_warehouse( cls, db: AsyncSession, query_object: WarehousePageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[dict[str, Any]]: - conditions = [] + conditions = [batch_scope_condition(BizWarehouse.test_batch_id, scope)] if query_object.warehouse_code: conditions.append(BizWarehouse.warehouse_code.like(f'%{query_object.warehouse_code}%')) if query_object.warehouse_name: @@ -441,6 +361,7 @@ class MasterDao: cls, db: AsyncSession, warehouse_id: int, + scope: TestBatchScope, *, for_update: bool = False, ) -> BizWarehouse | None: @@ -449,6 +370,7 @@ class MasterDao: BizWarehouse, BizWarehouse.warehouse_id, warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), for_update=for_update, ) @@ -457,25 +379,22 @@ class MasterDao: return await cls._find(db, BizWarehouse, BizWarehouse.warehouse_code, warehouse_code) @classmethod - async def add_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel) -> BizWarehouse: - return await cls._add(db, BizWarehouse, 'warehouse_id', warehouse) + async def add_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel, test_batch_id: int | None) -> BizWarehouse: + return await cls._add(db, BizWarehouse, 'warehouse_id', warehouse, test_batch_id) @classmethod - async def update_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel) -> None: - await cls._update(db, BizWarehouse, BizWarehouse.warehouse_id, 'warehouse_id', warehouse) - - @classmethod - async def delete_warehouse(cls, db: AsyncSession, warehouse_id: int) -> None: - await cls._delete(db, BizWarehouse, BizWarehouse.warehouse_id, warehouse_id) + async def update_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel, scope: TestBatchScope) -> None: + await cls._update(db, BizWarehouse, BizWarehouse.warehouse_id, 'warehouse_id', warehouse, scope) @classmethod async def list_pallet( cls, db: AsyncSession, query_object: PalletPageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[dict[str, Any]]: - conditions = [] + conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)] if query_object.pallet_code: conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) if query_object.asset_status: @@ -488,45 +407,18 @@ class MasterDao: cls, db: AsyncSession, pallet_id: int, + scope: TestBatchScope, *, for_update: bool = False, ) -> BizPallet | None: - statement = select(BizPallet).where(BizPallet.pallet_id == pallet_id) - if for_update: - statement = statement.with_for_update() - result = await db.execute(statement) - return result.scalar_one_or_none() - - @classmethod - async def get_pallet_removability( - cls, - db: AsyncSession, - pallet_ids: list[int], - ) -> dict[int, bool]: - """批量判断托盘是否仍为未入库且没有任何业务引用。""" - removability = dict.fromkeys(pallet_ids, False) - if not pallet_ids: - return removability - - has_unstocked_inventory = ( - select(BizPalletInventory.pallet_id) - .where( - BizPalletInventory.pallet_id == BizPallet.pallet_id, - BizPalletInventory.flow_state == PalletFlowState.UNSTOCKED.value, - ) - .exists() - ) - has_no_business_references = tuple( - ~select(column).where(column == BizPallet.pallet_id).exists() for column in _PALLET_REMOVAL_BLOCKING_COLUMNS - ) - statement = select( + return await cls._get( + db, + BizPallet, BizPallet.pallet_id, - and_(has_unstocked_inventory, *has_no_business_references).label('can_remove'), - ).where(BizPallet.pallet_id.in_(pallet_ids)) - result = await db.execute(statement) - for pallet_id, can_remove in result.all(): - removability[pallet_id] = bool(can_remove) - return removability + pallet_id, + batch_scope_condition(BizPallet.test_batch_id, scope), + for_update=for_update, + ) @classmethod async def find_pallet_by_code(cls, db: AsyncSession, pallet_code: str) -> BizPallet | None: @@ -543,12 +435,16 @@ class MasterDao: cls, db: AsyncSession, pallet: PalletEditModel, + scope: TestBatchScope, update_by: str, update_time: datetime, ) -> None: await db.execute( update(BizPallet) - .where(BizPallet.pallet_id == pallet.pallet_id) + .where( + BizPallet.pallet_id == pallet.pallet_id, + batch_scope_condition(BizPallet.test_batch_id, scope), + ) .values( capacity_packages=pallet.capacity_packages, asset_status=pallet.asset_status, @@ -558,21 +454,21 @@ class MasterDao: ) ) - @classmethod - async def delete_pallet(cls, db: AsyncSession, pallet_id: int) -> None: - await cls._delete(db, BizPallet, BizPallet.pallet_id, pallet_id) - @classmethod async def get_pallet_inventory( cls, db: AsyncSession, pallet_id: int, + scope: TestBatchScope, *, for_update: bool = False, ) -> BizPalletInventory | None: - statement = select(BizPalletInventory).where(BizPalletInventory.pallet_id == pallet_id) + statement = select(BizPalletInventory).where( + BizPalletInventory.pallet_id == pallet_id, + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) if for_update: - statement = statement.with_for_update() + statement = statement.with_for_update().execution_options(populate_existing=True) result = await db.execute(statement) return result.scalar_one_or_none() @@ -586,17 +482,6 @@ class MasterDao: await db.flush() return inventory - @classmethod - async def delete_pallet_inventory(cls, db: AsyncSession, pallet_id: int) -> None: - await cls._delete(db, BizPalletInventory, BizPalletInventory.pallet_id, pallet_id) - - @classmethod - async def count_pallet_events(cls, db: AsyncSession, pallet_id: int) -> int: - result = await db.execute( - select(func.count('*')).select_from(BizPalletEvent).where(BizPalletEvent.pallet_id == pallet_id) - ) - return result.scalar() or 0 - @classmethod async def list_assignable_users(cls, db: AsyncSession) -> list[Any]: result = await db.execute( @@ -622,31 +507,35 @@ class MasterDao: return list(result.all()) @classmethod - async def list_warehouse_assigned_user_ids(cls, db: AsyncSession, warehouse_id: int) -> list[int]: - result = await db.execute(select(BizUserWarehouse.user_id).where(BizUserWarehouse.warehouse_id == warehouse_id)) - return list(result.scalars().all()) - - @classmethod - async def list_site_assigned_user_ids(cls, db: AsyncSession, site_id: int) -> list[int]: - result = await db.execute(select(BizUserSite.user_id).where(BizUserSite.site_id == site_id)) - return list(result.scalars().all()) - - @classmethod - async def count_references( + async def list_warehouse_assigned_user_ids( cls, db: AsyncSession, - resource_type: MasterResource, - resource_id: int, - ) -> int: - """统计主数据的所有直接外键引用;同表的多个位置字段按一行计数。""" - references = _REFERENCE_COLUMNS.get(resource_type) - if references is None: - raise ValueError(f'不支持的主数据类型:{resource_type}') + warehouse_id: int, + scope: TestBatchScope, + ) -> list[int]: + result = await db.execute( + select(BizUserWarehouse.user_id) + .join(BizWarehouse, BizWarehouse.warehouse_id == BizUserWarehouse.warehouse_id) + .where( + BizUserWarehouse.warehouse_id == warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), + ) + ) + return list(result.scalars().all()) - total = 0 - for columns in references: - predicates = [column == resource_id for column in columns] - predicate = predicates[0] if len(predicates) == 1 else or_(*predicates) - result = await db.execute(select(func.count('*')).select_from(columns[0].class_).where(predicate)) - total += result.scalar() or 0 - return total + @classmethod + async def list_site_assigned_user_ids( + cls, + db: AsyncSession, + site_id: int, + scope: TestBatchScope, + ) -> list[int]: + result = await db.execute( + select(BizUserSite.user_id) + .join(BizCustomerSite, BizCustomerSite.site_id == BizUserSite.site_id) + .where( + BizUserSite.site_id == site_id, + batch_scope_condition(BizCustomerSite.test_batch_id, scope), + ) + ) + return list(result.scalars().all()) diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py index fb1cb64..a23c032 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py @@ -32,11 +32,6 @@ class StrainModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') - can_remove: bool | None = Field( - default=None, - description='是否允许删除', - json_schema_extra={'readOnly': True}, - ) class StrainPageQueryModel(BaseModel): @@ -91,11 +86,6 @@ class CustomerModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') - can_remove: bool | None = Field( - default=None, - description='是否允许删除', - json_schema_extra={'readOnly': True}, - ) class CustomerPageQueryModel(BaseModel): @@ -158,11 +148,6 @@ class SiteModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') - can_remove: bool | None = Field( - default=None, - description='是否允许删除', - json_schema_extra={'readOnly': True}, - ) class SitePageQueryModel(BaseModel): @@ -219,11 +204,6 @@ class LineModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') - can_remove: bool | None = Field( - default=None, - description='是否允许删除', - json_schema_extra={'readOnly': True}, - ) class LinePageQueryModel(BaseModel): @@ -280,11 +260,6 @@ class WarehouseModel(BaseModel): update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, max_length=500, description='备注') - can_remove: bool | None = Field( - default=None, - description='是否允许删除', - json_schema_extra={'readOnly': True}, - ) class WarehousePageQueryModel(BaseModel): @@ -341,7 +316,6 @@ class PalletModel(BaseModel): create_time: datetime | None = Field(default=None, description='创建时间') update_by: str | None = Field(default=None, max_length=64, description='更新者') update_time: datetime | None = Field(default=None, description='更新时间') - can_remove: bool | None = Field(default=None, description='是否允许删除') class PalletCreateModel(BaseModel): diff --git a/shierfeng-fastapi-backend/module_business/service/master_service.py b/shierfeng-fastapi-backend/module_business/service/master_service.py index 6f8917b..0beddf7 100644 --- a/shierfeng-fastapi-backend/module_business/service/master_service.py +++ b/shierfeng-fastapi-backend/module_business/service/master_service.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Any, Literal, NoReturn from pydantic import BaseModel from sqlalchemy.exc import IntegrityError @@ -6,7 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.vo import CrudResponseModel, PageModel from exceptions.exception import ServiceException, ServiceWarning +from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.master_dao import MasterDao +from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope from module_business.entity.vo.master_vo import ( CustomerModel, CustomerPageQueryModel, @@ -24,6 +26,7 @@ from module_business.entity.vo.master_vo import ( WarehouseModel, WarehousePageQueryModel, ) +from module_business.service.test_batch_access_service import TestBatchAccessService MasterResource = Literal['strain', 'customer', 'site', 'line', 'warehouse'] @@ -71,17 +74,35 @@ class MasterService: """菌种、客户、现场、产线和仓库主数据服务层。""" @staticmethod - async def _validate_parent(db: AsyncSession, resource: MasterResource, page_object: BaseModel) -> None: + async def _prepare_test_write( + db: AsyncSession, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> None: + if scope.mode != 'TEST': + return + TestBatchAccessService.assert_super_admin(current_user) + await TestBatchAccessService.lock_active_batch(db, scope) + + @staticmethod + async def _validate_parent( + db: AsyncSession, + resource: MasterResource, + page_object: BaseModel, + scope: TestBatchScope, + ) -> None: if resource == 'site': - customer = await MasterDao.get_customer(db, page_object.customer_id) + customer = await MasterDao.get_customer(db, page_object.customer_id, scope) if customer is None: raise ServiceException(message='目标客户不存在') + assert_record_in_scope(customer, scope, '目标客户') if customer.status != '0': raise ServiceException(message='目标客户已停用') elif resource == 'line': - site = await MasterDao.get_site(db, page_object.site_id) + site = await MasterDao.get_site(db, page_object.site_id, scope) if site is None: raise ServiceException(message='目标客户现场不存在') + assert_record_in_scope(site, scope, '目标客户现场') if site.status != '0': raise ServiceException(message='目标客户现场已停用') @@ -127,50 +148,37 @@ class MasterService: code = getattr(page_object, config['code_field']) raise ServiceException(message=f'{config["label"]}编码{code}已存在') - @staticmethod - def _extract_ids(resource: MasterResource, value: int | BaseModel) -> list[int]: - ids = [value] if isinstance(value, int) else list(getattr(value, _RESOURCE_CONFIG[resource]['ids_field'])) - if not ids or any(resource_id <= 0 for resource_id in ids): - raise ServiceException(message='主数据ID必须为正整数') - if len(ids) != len(set(ids)): - raise ServiceException(message='主数据ID不能重复') - return ids - @classmethod async def _list( cls, db: AsyncSession, resource: MasterResource, query_object: BaseModel, + scope: TestBatchScope, is_page: bool, ) -> PageModel | list[BaseModel]: - result = await getattr(MasterDao, f'list_{resource}')(db, query_object, is_page) + result = await getattr(MasterDao, f'list_{resource}')(db, query_object, scope, is_page) config = _RESOURCE_CONFIG[resource] records = result.rows if isinstance(result, PageModel) else result - models = [config['vo'].model_validate(record) for record in records] - id_field = config['id_field'] - removability = await MasterDao.get_master_removability( - db, - resource, - [getattr(model, id_field) for model in models], - ) - rows = [ - model.model_copy(update={'can_remove': removability.get(getattr(model, id_field), False)}) - for model in models - ] + rows = [config['vo'].model_validate(record) for record in records] if isinstance(result, PageModel): return result.model_copy(update={'rows': rows}) return rows @classmethod - async def _detail(cls, db: AsyncSession, resource: MasterResource, resource_id: int) -> BaseModel: + async def _detail( + cls, + db: AsyncSession, + resource: MasterResource, + resource_id: int, + scope: TestBatchScope, + ) -> BaseModel: config = _RESOURCE_CONFIG[resource] - record = await getattr(MasterDao, f'get_{resource}')(db, resource_id) + record = await getattr(MasterDao, f'get_{resource}')(db, resource_id, scope) if record is None: raise ServiceException(message=f'{config["label"]}不存在') - model = config['vo'].model_validate(record) - removability = await MasterDao.get_master_removability(db, resource, [resource_id]) - return model.model_copy(update={'can_remove': removability.get(resource_id, False)}) + assert_record_in_scope(record, scope, config['label']) + return config['vo'].model_validate(record) @classmethod async def _add( @@ -178,13 +186,16 @@ class MasterService: db: AsyncSession, resource: MasterResource, page_object: BaseModel, + current_user: CurrentUserModel, + scope: TestBatchScope, ) -> CrudResponseModel: config = _RESOURCE_CONFIG[resource] try: - await cls._validate_parent(db, resource, page_object) + await cls._prepare_test_write(db, current_user, scope) + await cls._validate_parent(db, resource, page_object, scope) await cls._validate_unique(db, resource, page_object) safe_object = page_object.model_copy(update={config['id_field']: None}) - await getattr(MasterDao, f'add_{resource}')(db, safe_object) + await getattr(MasterDao, f'add_{resource}')(db, safe_object, scope.batch_id) await db.commit() return CrudResponseModel(is_success=True, message='新增成功') except IntegrityError: @@ -200,18 +211,27 @@ class MasterService: db: AsyncSession, resource: MasterResource, page_object: BaseModel, + current_user: CurrentUserModel, + scope: TestBatchScope, ) -> CrudResponseModel: config = _RESOURCE_CONFIG[resource] try: + await cls._prepare_test_write(db, current_user, scope) resource_id = getattr(page_object, config['id_field']) if resource_id is None: raise ServiceException(message=f'{config["label"]}ID不能为空') - current = await getattr(MasterDao, f'get_{resource}')(db, resource_id) + current = await getattr(MasterDao, f'get_{resource}')( + db, + resource_id, + scope, + for_update=scope.mode == 'TEST', + ) if current is None: raise ServiceException(message=f'{config["label"]}不存在') - await cls._validate_parent(db, resource, page_object) + assert_record_in_scope(current, scope, config['label']) + await cls._validate_parent(db, resource, page_object, scope) await cls._validate_unique(db, resource, page_object, resource_id) - await getattr(MasterDao, f'update_{resource}')(db, page_object) + await getattr(MasterDao, f'update_{resource}')(db, page_object, scope) await db.commit() return CrudResponseModel(is_success=True, message='更新成功') except IntegrityError: @@ -221,173 +241,216 @@ class MasterService: await db.rollback() raise - @classmethod - async def _remove( - cls, - db: AsyncSession, - resource: MasterResource, - value: int | BaseModel, - ) -> CrudResponseModel: - config = _RESOURCE_CONFIG[resource] - try: - resource_ids = cls._extract_ids(resource, value) - for resource_id in resource_ids: - if await MasterDao.count_references(db, resource, resource_id) > 0: - raise ServiceWarning(message=f'{config["label"]}已被引用,只能停用,不能删除') - if await getattr(MasterDao, f'get_{resource}')(db, resource_id) is None: - raise ServiceException(message=f'{config["label"]}不存在') - for resource_id in resource_ids: - await getattr(MasterDao, f'delete_{resource}')(db, resource_id) - await db.commit() - return CrudResponseModel(is_success=True, message='删除成功') - except IntegrityError: - await db.rollback() - raise ServiceException(message=f'删除{config["label"]}失败,该数据仍被引用') from None - except Exception: - await db.rollback() - raise + @staticmethod + async def reject_physical_delete() -> NoReturn: + raise ServiceWarning('', '不支持单条物理删除,请通过测试数据批次清理') @classmethod async def list_strain( cls, db: AsyncSession, query_object: StrainPageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[StrainModel]: - return await cls._list(db, 'strain', query_object, is_page) + return await cls._list(db, 'strain', query_object, scope, is_page) @classmethod - async def detail_strain(cls, db: AsyncSession, strain_id: int) -> StrainModel: - return await cls._detail(db, 'strain', strain_id) + async def detail_strain(cls, db: AsyncSession, strain_id: int, scope: TestBatchScope) -> StrainModel: + return await cls._detail(db, 'strain', strain_id, scope) @classmethod - async def add_strain(cls, db: AsyncSession, strain: StrainModel) -> CrudResponseModel: - return await cls._add(db, 'strain', strain) + async def add_strain( + cls, + db: AsyncSession, + strain: StrainModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._add(db, 'strain', strain, current_user, scope) @classmethod - async def edit_strain(cls, db: AsyncSession, strain: StrainModel) -> CrudResponseModel: - return await cls._edit(db, 'strain', strain) + async def edit_strain( + cls, + db: AsyncSession, + strain: StrainModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._edit(db, 'strain', strain, current_user, scope) @classmethod async def remove_strain( cls, db: AsyncSession, value: int | DeleteStrainModel, - ) -> CrudResponseModel: - return await cls._remove(db, 'strain', value) + ) -> NoReturn: + return await cls.reject_physical_delete() @classmethod async def list_customer( cls, db: AsyncSession, query_object: CustomerPageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[CustomerModel]: - return await cls._list(db, 'customer', query_object, is_page) + return await cls._list(db, 'customer', query_object, scope, is_page) @classmethod - async def detail_customer(cls, db: AsyncSession, customer_id: int) -> CustomerModel: - return await cls._detail(db, 'customer', customer_id) + async def detail_customer(cls, db: AsyncSession, customer_id: int, scope: TestBatchScope) -> CustomerModel: + return await cls._detail(db, 'customer', customer_id, scope) @classmethod - async def add_customer(cls, db: AsyncSession, customer: CustomerModel) -> CrudResponseModel: - return await cls._add(db, 'customer', customer) + async def add_customer( + cls, + db: AsyncSession, + customer: CustomerModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._add(db, 'customer', customer, current_user, scope) @classmethod - async def edit_customer(cls, db: AsyncSession, customer: CustomerModel) -> CrudResponseModel: - return await cls._edit(db, 'customer', customer) + async def edit_customer( + cls, + db: AsyncSession, + customer: CustomerModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._edit(db, 'customer', customer, current_user, scope) @classmethod async def remove_customer( cls, db: AsyncSession, value: int | DeleteCustomerModel, - ) -> CrudResponseModel: - return await cls._remove(db, 'customer', value) + ) -> NoReturn: + return await cls.reject_physical_delete() @classmethod async def list_site( cls, db: AsyncSession, query_object: SitePageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[SiteModel]: - return await cls._list(db, 'site', query_object, is_page) + return await cls._list(db, 'site', query_object, scope, is_page) @classmethod - async def detail_site(cls, db: AsyncSession, site_id: int) -> SiteModel: - return await cls._detail(db, 'site', site_id) + async def detail_site(cls, db: AsyncSession, site_id: int, scope: TestBatchScope) -> SiteModel: + return await cls._detail(db, 'site', site_id, scope) @classmethod - async def add_site(cls, db: AsyncSession, site: SiteModel) -> CrudResponseModel: - return await cls._add(db, 'site', site) + async def add_site( + cls, + db: AsyncSession, + site: SiteModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._add(db, 'site', site, current_user, scope) @classmethod - async def edit_site(cls, db: AsyncSession, site: SiteModel) -> CrudResponseModel: - return await cls._edit(db, 'site', site) + async def edit_site( + cls, + db: AsyncSession, + site: SiteModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._edit(db, 'site', site, current_user, scope) @classmethod async def remove_site( cls, db: AsyncSession, value: int | DeleteSiteModel, - ) -> CrudResponseModel: - return await cls._remove(db, 'site', value) + ) -> NoReturn: + return await cls.reject_physical_delete() @classmethod async def list_line( cls, db: AsyncSession, query_object: LinePageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[LineModel]: - return await cls._list(db, 'line', query_object, is_page) + return await cls._list(db, 'line', query_object, scope, is_page) @classmethod - async def detail_line(cls, db: AsyncSession, line_id: int) -> LineModel: - return await cls._detail(db, 'line', line_id) + async def detail_line(cls, db: AsyncSession, line_id: int, scope: TestBatchScope) -> LineModel: + return await cls._detail(db, 'line', line_id, scope) @classmethod - async def add_line(cls, db: AsyncSession, line: LineModel) -> CrudResponseModel: - return await cls._add(db, 'line', line) + async def add_line( + cls, + db: AsyncSession, + line: LineModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._add(db, 'line', line, current_user, scope) @classmethod - async def edit_line(cls, db: AsyncSession, line: LineModel) -> CrudResponseModel: - return await cls._edit(db, 'line', line) + async def edit_line( + cls, + db: AsyncSession, + line: LineModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._edit(db, 'line', line, current_user, scope) @classmethod async def remove_line( cls, db: AsyncSession, value: int | DeleteLineModel, - ) -> CrudResponseModel: - return await cls._remove(db, 'line', value) + ) -> NoReturn: + return await cls.reject_physical_delete() @classmethod async def list_warehouse( cls, db: AsyncSession, query_object: WarehousePageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[WarehouseModel]: - return await cls._list(db, 'warehouse', query_object, is_page) + return await cls._list(db, 'warehouse', query_object, scope, is_page) @classmethod - async def detail_warehouse(cls, db: AsyncSession, warehouse_id: int) -> WarehouseModel: - return await cls._detail(db, 'warehouse', warehouse_id) + async def detail_warehouse(cls, db: AsyncSession, warehouse_id: int, scope: TestBatchScope) -> WarehouseModel: + return await cls._detail(db, 'warehouse', warehouse_id, scope) @classmethod - async def add_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel) -> CrudResponseModel: - return await cls._add(db, 'warehouse', warehouse) + async def add_warehouse( + cls, + db: AsyncSession, + warehouse: WarehouseModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._add(db, 'warehouse', warehouse, current_user, scope) @classmethod - async def edit_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel) -> CrudResponseModel: - return await cls._edit(db, 'warehouse', warehouse) + async def edit_warehouse( + cls, + db: AsyncSession, + warehouse: WarehouseModel, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> CrudResponseModel: + return await cls._edit(db, 'warehouse', warehouse, current_user, scope) @classmethod async def remove_warehouse( cls, db: AsyncSession, value: int | DeleteWarehouseModel, - ) -> CrudResponseModel: - return await cls._remove(db, 'warehouse', value) + ) -> NoReturn: + return await cls.reject_physical_delete() diff --git a/shierfeng-fastapi-backend/module_business/service/pallet_service.py b/shierfeng-fastapi-backend/module_business/service/pallet_service.py index a227a14..7311ebf 100644 --- a/shierfeng-fastapi-backend/module_business/service/pallet_service.py +++ b/shierfeng-fastapi-backend/module_business/service/pallet_service.py @@ -1,16 +1,18 @@ import secrets from datetime import datetime from io import BytesIO -from typing import Literal +from typing import Literal, NoReturn import qrcode from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from common.vo import CrudResponseModel, PageModel -from exceptions.exception import ServiceException +from exceptions.exception import ServiceException, ServiceWarning +from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.access_dao import AccessDao from module_business.dao.master_dao import MasterDao +from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import BizPalletInventory from module_business.entity.vo.master_vo import ( @@ -22,6 +24,7 @@ from module_business.entity.vo.master_vo import ( PalletPageQueryModel, UserAssignModel, ) +from module_business.service.test_batch_access_service import TestBatchAccessService AssignmentResource = Literal['warehouse', 'site'] @@ -41,33 +44,43 @@ class PalletService: qrcode.make(qr_token).save(buffer, format='PNG') return buffer.getvalue() + @staticmethod + async def _prepare_test_write( + db: AsyncSession, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> None: + if scope.mode != 'TEST': + return + TestBatchAccessService.assert_super_admin(current_user) + await TestBatchAccessService.lock_active_batch(db, scope) + @classmethod - async def get_pallet_qrcode(cls, db: AsyncSession, pallet_id: int) -> bytes: - pallet = await MasterDao.get_pallet(db, pallet_id) + async def get_pallet_qrcode(cls, db: AsyncSession, pallet_id: int, scope: TestBatchScope) -> bytes: + pallet = await MasterDao.get_pallet(db, pallet_id, scope) if pallet is None: raise ServiceException(message='托盘不存在') + assert_record_in_scope(pallet, scope, '托盘') return cls.render_qr_png(pallet.qr_token) - @staticmethod - def _extract_pallet_ids(value: int | DeletePalletModel) -> list[int]: - pallet_ids = [value] if isinstance(value, int) else list(value.pallet_ids) - if not pallet_ids or any(pallet_id <= 0 for pallet_id in pallet_ids): - raise ServiceException(message='托盘ID必须为正整数') - if len(pallet_ids) != len(set(pallet_ids)): - raise ServiceException(message='托盘ID不能重复') - return sorted(pallet_ids) - @classmethod - async def _lock_pallet_inventory(cls, db: AsyncSession, pallet_id: int) -> tuple[BizPallet, BizPalletInventory]: + async def _lock_pallet_inventory( + cls, + db: AsyncSession, + pallet_id: int, + scope: TestBatchScope, + ) -> tuple[BizPallet, BizPalletInventory]: """按库存、托盘顺序加锁,避免与流转事务形成反序死锁。""" - inventory = await MasterDao.get_pallet_inventory(db, pallet_id, for_update=True) + inventory = await MasterDao.get_pallet_inventory(db, pallet_id, scope, for_update=True) if inventory is None: - if await MasterDao.get_pallet(db, pallet_id) is None: + if await MasterDao.get_pallet(db, pallet_id, scope) is None: raise ServiceException(message='托盘不存在') raise ServiceException(message='托盘库存不存在') - pallet = await MasterDao.get_pallet(db, pallet_id, for_update=True) + assert_record_in_scope(inventory, scope, '托盘库存') + pallet = await MasterDao.get_pallet(db, pallet_id, scope, for_update=True) if pallet is None: raise ServiceException(message='托盘不存在') + assert_record_in_scope(pallet, scope, '托盘') return pallet, inventory @classmethod @@ -75,42 +88,41 @@ class PalletService: cls, db: AsyncSession, query_object: PalletPageQueryModel, + scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[PalletModel]: - result = await MasterDao.list_pallet(db, query_object, is_page) + result = await MasterDao.list_pallet(db, query_object, scope, is_page) records = result.rows if isinstance(result, PageModel) else result - models = [PalletModel.model_validate(record) for record in records] - removability = await MasterDao.get_pallet_removability( - db, - [model.pallet_id for model in models], - ) - rows = [model.model_copy(update={'can_remove': removability.get(model.pallet_id, False)}) for model in models] + rows = [PalletModel.model_validate(record) for record in records] if isinstance(result, PageModel): return result.model_copy(update={'rows': rows}) return rows @classmethod - async def detail_pallet(cls, db: AsyncSession, pallet_id: int) -> PalletModel: - pallet = await MasterDao.get_pallet(db, pallet_id) + async def detail_pallet(cls, db: AsyncSession, pallet_id: int, scope: TestBatchScope) -> PalletModel: + pallet = await MasterDao.get_pallet(db, pallet_id, scope) if pallet is None: raise ServiceException(message='托盘不存在') - model = PalletModel.model_validate(pallet) - removability = await MasterDao.get_pallet_removability(db, [pallet_id]) - return model.model_copy(update={'can_remove': removability.get(pallet_id, False)}) + assert_record_in_scope(pallet, scope, '托盘') + return PalletModel.model_validate(pallet) @classmethod async def add_pallet( cls, db: AsyncSession, pallet: PalletCreateModel, - current_user_name: str, + current_user: CurrentUserModel, + scope: TestBatchScope, ) -> CrudResponseModel: try: + await cls._prepare_test_write(db, current_user, scope) if await MasterDao.find_pallet_by_code(db, pallet.pallet_code) is not None: raise ServiceException(message='托盘编号已存在') current_time = datetime.now() + current_user_name = current_user.user.user_name pallet_record = BizPallet( + test_batch_id=scope.batch_id, pallet_code=pallet.pallet_code, qr_token=secrets.token_urlsafe(32), capacity_packages=pallet.capacity_packages, @@ -124,6 +136,7 @@ class PalletService: pallet_record = await MasterDao.add_pallet(db, pallet_record) inventory = BizPalletInventory( pallet_id=pallet_record.pallet_id, + test_batch_id=scope.batch_id, flow_state='UNSTOCKED', remaining_packages=0, version=0, @@ -144,16 +157,19 @@ class PalletService: cls, db: AsyncSession, pallet: PalletEditModel, - current_user_name: str, + current_user: CurrentUserModel, + scope: TestBatchScope, ) -> CrudResponseModel: try: - _, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id) + await cls._prepare_test_write(db, current_user, scope) + _, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope) if pallet.capacity_packages < inventory.remaining_packages: raise ServiceException(message='托盘容量不能小于当前剩余包数') await MasterDao.update_pallet( db, pallet, - update_by=current_user_name, + scope, + update_by=current_user.user.user_name, update_time=datetime.now(), ) await db.commit() @@ -170,27 +186,8 @@ class PalletService: cls, db: AsyncSession, value: int | DeletePalletModel, - ) -> CrudResponseModel: - try: - pallet_ids = cls._extract_pallet_ids(value) - for pallet_id in pallet_ids: - await cls._lock_pallet_inventory(db, pallet_id) - - removability = await MasterDao.get_pallet_removability(db, pallet_ids) - if not all(removability.get(pallet_id, False) for pallet_id in pallet_ids): - raise ServiceException(message='托盘状态或关联数据不允许删除') - - for pallet_id in pallet_ids: - await MasterDao.delete_pallet_inventory(db, pallet_id) - await MasterDao.delete_pallet(db, pallet_id) - await db.commit() - return CrudResponseModel(is_success=True, message='删除成功') - except IntegrityError: - await db.rollback() - raise ServiceException(message='删除托盘失败,托盘状态或关联数据已变化') from None - except Exception: - await db.rollback() - raise + ) -> NoReturn: + raise ServiceWarning('', '不支持单条物理删除,请通过测试数据批次清理') @classmethod async def _list_resource_users( @@ -198,12 +195,17 @@ class PalletService: db: AsyncSession, resource: AssignmentResource, resource_id: int, + scope: TestBatchScope, ) -> list[BusinessUserOptionModel]: - if await getattr(MasterDao, f'get_{resource}')(db, resource_id) is None: + record = await getattr(MasterDao, f'get_{resource}')(db, resource_id, scope) + if record is None: raise ServiceException(message=f'{_ASSIGNMENT_LABELS[resource]}不存在') + assert_record_in_scope(record, scope, _ASSIGNMENT_LABELS[resource]) users = await MasterDao.list_assignable_users(db) - assigned_user_ids = set(await getattr(MasterDao, f'list_{resource}_assigned_user_ids')(db, resource_id)) + assigned_user_ids = set( + await getattr(MasterDao, f'list_{resource}_assigned_user_ids')(db, resource_id, scope) + ) return [ BusinessUserOptionModel( user_id=user.user_id, @@ -219,16 +221,18 @@ class PalletService: cls, db: AsyncSession, warehouse_id: int, + scope: TestBatchScope, ) -> list[BusinessUserOptionModel]: - return await cls._list_resource_users(db, 'warehouse', warehouse_id) + return await cls._list_resource_users(db, 'warehouse', warehouse_id, scope) @classmethod async def list_site_users( cls, db: AsyncSession, site_id: int, + scope: TestBatchScope, ) -> list[BusinessUserOptionModel]: - return await cls._list_resource_users(db, 'site', site_id) + return await cls._list_resource_users(db, 'site', site_id, scope) @classmethod async def _assign_resource_users( @@ -237,18 +241,21 @@ class PalletService: resource: AssignmentResource, resource_id: int, assignment: UserAssignModel, + current_user: CurrentUserModel, + scope: TestBatchScope, ) -> CrudResponseModel: label = _ASSIGNMENT_LABELS[resource] try: - if ( - await getattr(MasterDao, f'get_{resource}')( - db, - resource_id, - for_update=True, - ) - is None - ): + await cls._prepare_test_write(db, current_user, scope) + record = await getattr(MasterDao, f'get_{resource}')( + db, + resource_id, + scope, + for_update=True, + ) + if record is None: raise ServiceException(message=f'{label}不存在') + assert_record_in_scope(record, scope, label) users = await MasterDao.list_assignable_users_by_ids(db, assignment.user_ids) if {user.user_id for user in users} != set(assignment.user_ids): raise ServiceException(message='存在不存在、停用或已删除的用户') @@ -268,8 +275,17 @@ class PalletService: db: AsyncSession, warehouse_id: int, assignment: UserAssignModel, + current_user: CurrentUserModel, + scope: TestBatchScope, ) -> CrudResponseModel: - return await cls._assign_resource_users(db, 'warehouse', warehouse_id, assignment) + return await cls._assign_resource_users( + db, + 'warehouse', + warehouse_id, + assignment, + current_user, + scope, + ) @classmethod async def assign_site_users( @@ -277,5 +293,14 @@ class PalletService: db: AsyncSession, site_id: int, assignment: UserAssignModel, + current_user: CurrentUserModel, + scope: TestBatchScope, ) -> CrudResponseModel: - return await cls._assign_resource_users(db, 'site', site_id, assignment) + return await cls._assign_resource_users( + db, + 'site', + site_id, + assignment, + current_user, + scope, + ) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_master_service.py b/shierfeng-fastapi-backend/tests/module_business/test_master_service.py index c846003..d608af3 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_master_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_master_service.py @@ -1,5 +1,4 @@ import inspect -import re import unittest from contextlib import ExitStack from datetime import datetime @@ -9,14 +8,13 @@ from unittest.mock import AsyncMock, MagicMock, patch from fastapi import FastAPI from fastapi.routing import APIRoute -from sqlalchemy.dialects import mysql, postgresql from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from common.aspect.interface_auth import CheckUserInterfaceAuth from common.aspect.pre_auth import PreAuth from common.vo import CrudResponseModel, DataResponseModel, PageModel, PageResponseModel -from exceptions.exception import ServiceException, ServiceWarning +from exceptions.exception import PermissionException, ServiceException, ServiceWarning from module_admin.service.login_service import LoginService from module_business.controller import ( customer_controller as customer_controller_module, @@ -26,6 +24,8 @@ from module_business.controller import site_controller as site_controller_module from module_business.controller import strain_controller as strain_controller_module from module_business.controller import warehouse_controller as warehouse_controller_module from module_business.dao.master_dao import MasterDao +from module_business.dependency.test_batch_dependency import TestBatchScopeResolver +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import ( BizCustomer, BizCustomerSite, @@ -51,6 +51,8 @@ from module_business.entity.vo.master_vo import ( WarehousePageQueryModel, ) from module_business.service.master_service import MasterService +from module_business.service.test_batch_access_service import TestBatchAccessService +from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope from utils.page_util import PageUtil RESOURCE_IDS = { @@ -97,124 +99,61 @@ def integrity_error() -> IntegrityError: class TestMasterService(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) + self.admin = make_admin() + self.scope = TestBatchScope.formal() self.strain = StrainModel(strainCode='S001', strainName='测试菌种') self.line = LineModel(siteId=1, lineCode='L001', lineName='测试产线') def _patch_enabled_parent(self, stack: ExitStack, resource: str) -> None: if resource == 'site': stack.enter_context( - patch.object(MasterDao, 'get_customer', new=AsyncMock(return_value=SimpleNamespace(status='0'))) + patch.object( + MasterDao, + 'get_customer', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ) ) elif resource == 'line': stack.enter_context( - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))) - ) - - async def test_list_populates_true_and_false_removability_for_all_resources(self) -> None: - for resource, id_field in RESOURCE_IDS.items(): - rows = [make_models(1)[resource], make_models(2)[resource]] - page = PageModel(rows=rows, pageNum=1, pageSize=10, total=2, hasNext=False) - removability_mock = AsyncMock(return_value={1: True, 2: False}) - with ( - self.subTest(resource=resource), - patch.object(MasterDao, f'list_{resource}', new=AsyncMock(return_value=page)), patch.object( MasterDao, - 'get_master_removability', - new=removability_mock, - create=True, - ), - ): - result = await getattr(MasterService, f'list_{resource}')(self.db, MagicMock()) - - self.assertEqual( - [True, False], - [row.model_dump().get('can_remove') for row in result.rows], - ) - removability_mock.assert_awaited_once_with( - self.db, - resource, - [getattr(row, id_field) for row in rows], + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), ) + ) - async def test_detail_populates_true_and_false_removability_for_all_resources(self) -> None: + async def test_formal_lists_return_models_and_forward_scope_for_all_resources(self) -> None: for resource in RESOURCE_IDS: - for can_remove in (True, False): - record = make_models(7)[resource] - removability_mock = AsyncMock(return_value={7: can_remove}) - with ( - self.subTest(resource=resource, can_remove=can_remove), - patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=record)), - patch.object( - MasterDao, - 'get_master_removability', - new=removability_mock, - create=True, - ), - ): - result = await getattr(MasterService, f'detail_{resource}')(self.db, 7) - - self.assertIs(can_remove, result.model_dump().get('can_remove')) - removability_mock.assert_awaited_once_with(self.db, resource, [7]) - - async def test_non_paginated_list_populates_removability_for_all_resources(self) -> None: - for resource, id_field in RESOURCE_IDS.items(): - records = [make_models(1)[resource], make_models(2)[resource]] - removability_mock = AsyncMock(return_value={1: False, 2: True}) + rows = [make_models(1)[resource], make_models(2)[resource]] + page = PageModel(rows=rows, pageNum=1, pageSize=10, total=2, hasNext=False) + list_mock = AsyncMock(return_value=page) + query = MagicMock() with ( self.subTest(resource=resource), - patch.object(MasterDao, f'list_{resource}', new=AsyncMock(return_value=records)) as list_mock, - patch.object(MasterDao, 'get_master_removability', new=removability_mock), + patch.object(MasterDao, f'list_{resource}', new=list_mock), ): - query = MagicMock() - result = await getattr(MasterService, f'list_{resource}')(self.db, query, is_page=False) - - self.assertIsInstance(result, list) - self.assertEqual( - [False, True], - [row.model_dump().get('can_remove') for row in result], - ) - list_mock.assert_awaited_once_with(self.db, query, False) - removability_mock.assert_awaited_once_with( + result = await getattr(MasterService, f'list_{resource}')( self.db, - resource, - [getattr(record, id_field) for record in records], + query, + self.scope, ) - def test_removability_uses_camel_case_json_alias_for_all_resources(self) -> None: - for resource, model in make_models(1).items(): - with self.subTest(resource=resource): - response_model = model.model_copy(update={'can_remove': True}) - - payload = response_model.model_dump(by_alias=True) - - self.assertIs(True, payload.get('canRemove')) - self.assertNotIn('can_remove', payload) - self.assertIs( - True, - type(model).model_json_schema(by_alias=True)['properties']['canRemove'].get('readOnly'), - ) + self.assertTrue(all(isinstance(row, type(rows[0])) for row in result.rows)) + list_mock.assert_awaited_once_with(self.db, query, self.scope, True) async def test_duplicate_strain_code_is_rejected(self) -> None: with ( patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=object())), self.assertRaises(ServiceException), ): - await MasterService.add_strain(self.db, self.strain) + await MasterService.add_strain(self.db, self.strain, self.admin, self.scope) async def test_line_must_belong_to_enabled_site(self) -> None: with ( patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException), ): - await MasterService.add_line(self.db, self.line) - - async def test_referenced_master_can_only_be_disabled(self) -> None: - with ( - patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=1)), - self.assertRaises(ServiceWarning), - ): - await MasterService.remove_strain(self.db, 1) + await MasterService.add_line(self.db, self.line, self.admin, self.scope) async def test_duplicate_code_is_rejected_for_all_five_resources(self) -> None: for resource, model in make_models().items(): @@ -228,12 +167,17 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): ) ) with self.assertRaises(ServiceException): - await getattr(MasterService, f'add_{resource}')(self.db, model) + await getattr(MasterService, f'add_{resource}')( + self.db, + model, + self.admin, + self.scope, + ) async def test_edit_unique_check_excludes_current_record_for_all_resources(self) -> None: for resource, model in make_models(7).items(): id_field = RESOURCE_IDS[resource] - current = SimpleNamespace(**{id_field: 7}) + current = SimpleNamespace(**{id_field: 7}, test_batch_id=None) with self.subTest(resource=resource), ExitStack() as stack: self.db.reset_mock() self._patch_enabled_parent(stack, resource) @@ -243,7 +187,12 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): ) update_mock = stack.enter_context(patch.object(MasterDao, f'update_{resource}', new=AsyncMock())) - result = await getattr(MasterService, f'edit_{resource}')(self.db, model) + result = await getattr(MasterService, f'edit_{resource}')( + self.db, + model, + self.admin, + self.scope, + ) self.assertIsInstance(result, CrudResponseModel) self.assertTrue(result.is_success) @@ -259,7 +208,7 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, f'get_{resource}', - new=AsyncMock(return_value=SimpleNamespace(**{id_field: 7})), + new=AsyncMock(return_value=SimpleNamespace(**{id_field: 7}, test_batch_id=None)), ) ) stack.enter_context( @@ -271,46 +220,55 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): ) with self.assertRaises(ServiceException): - await getattr(MasterService, f'edit_{resource}')(self.db, model) + await getattr(MasterService, f'edit_{resource}')( + self.db, + model, + self.admin, + self.scope, + ) async def test_site_add_requires_existing_enabled_customer(self) -> None: site = make_models()['site'] - for parent in (None, SimpleNamespace(status='1')): + for parent in (None, SimpleNamespace(status='1', test_batch_id=None)): with ( self.subTest(parent=parent), patch.object(MasterDao, 'get_customer', new=AsyncMock(return_value=parent)), self.assertRaises(ServiceException), ): - await MasterService.add_site(self.db, site) + await MasterService.add_site(self.db, site, self.admin, self.scope) with ( patch.object( MasterDao, 'get_customer', - new=AsyncMock(return_value=SimpleNamespace(status='0')), + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), ), patch.object(MasterDao, 'find_site_by_code', new=AsyncMock(return_value=None)), patch.object(MasterDao, 'add_site', new=AsyncMock()), ): - result = await MasterService.add_site(self.db, site) + result = await MasterService.add_site(self.db, site, self.admin, self.scope) self.assertTrue(result.is_success) async def test_line_add_requires_existing_enabled_site(self) -> None: line = make_models()['line'] - for parent in (None, SimpleNamespace(status='1')): + for parent in (None, SimpleNamespace(status='1', test_batch_id=None)): with ( self.subTest(parent=parent), patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=parent)), self.assertRaises(ServiceException), ): - await MasterService.add_line(self.db, line) + await MasterService.add_line(self.db, line, self.admin, self.scope) with ( - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), + ), patch.object(MasterDao, 'find_line_by_code', new=AsyncMock(return_value=None)), patch.object(MasterDao, 'add_line', new=AsyncMock()), ): - result = await MasterService.add_line(self.db, line) + result = await MasterService.add_line(self.db, line, self.admin, self.scope) self.assertTrue(result.is_success) async def test_site_and_line_edit_require_enabled_target_parent(self) -> None: @@ -319,39 +277,59 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): ('line', make_models(7)['line'], 'get_site'), ) for resource, model, parent_method in cases: - for parent in (None, SimpleNamespace(status='1')): + for parent in (None, SimpleNamespace(status='1', test_batch_id=None)): with self.subTest(resource=resource, parent=parent), ExitStack() as stack: stack.enter_context( patch.object( MasterDao, f'get_{resource}', - new=AsyncMock(return_value=SimpleNamespace(**{RESOURCE_IDS[resource]: 7})), + new=AsyncMock( + return_value=SimpleNamespace( + **{RESOURCE_IDS[resource]: 7}, + test_batch_id=None, + ) + ), ) ) stack.enter_context(patch.object(MasterDao, parent_method, new=AsyncMock(return_value=parent))) with self.assertRaises(ServiceException): - await getattr(MasterService, f'edit_{resource}')(self.db, model) + await getattr(MasterService, f'edit_{resource}')( + self.db, + model, + self.admin, + self.scope, + ) with self.subTest(resource=resource, parent='enabled'), ExitStack() as stack: stack.enter_context( patch.object( MasterDao, f'get_{resource}', - new=AsyncMock(return_value=SimpleNamespace(**{RESOURCE_IDS[resource]: 7})), + new=AsyncMock( + return_value=SimpleNamespace( + **{RESOURCE_IDS[resource]: 7}, + test_batch_id=None, + ) + ), ) ) stack.enter_context( patch.object( MasterDao, parent_method, - new=AsyncMock(return_value=SimpleNamespace(status='0')), + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=None)), ) ) stack.enter_context( patch.object(MasterDao, f'find_{resource}_by_code', new=AsyncMock(return_value=None)) ) stack.enter_context(patch.object(MasterDao, f'update_{resource}', new=AsyncMock())) - result = await getattr(MasterService, f'edit_{resource}')(self.db, model) + result = await getattr(MasterService, f'edit_{resource}')( + self.db, + model, + self.admin, + self.scope, + ) self.assertTrue(result.is_success) async def test_detail_missing_is_explicit_for_all_resources(self) -> None: @@ -361,61 +339,235 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException), ): - await getattr(MasterService, f'detail_{resource}')(self.db, 99) + await getattr(MasterService, f'detail_{resource}')(self.db, 99, self.scope) - async def test_reference_delete_protection_covers_all_resources(self) -> None: + +class TestMasterBatchScopeService(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.db = MagicMock(spec=AsyncSession) + self.admin = make_admin() + self.scope = make_test_scope(9) + self.formal_scope = TestBatchScope.formal() + + async def test_lists_and_details_forward_trusted_scope_to_dao(self) -> None: for resource in RESOURCE_IDS: + query = MagicMock() + page = PageModel(rows=[], pageNum=1, pageSize=10, total=0, hasNext=False) + list_mock = AsyncMock(return_value=page) + detail_model = make_models(7)[resource] + detail_record = SimpleNamespace(**detail_model.model_dump(), test_batch_id=9) + detail_mock = AsyncMock(return_value=detail_record) with ( self.subTest(resource=resource), - patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=1)), + patch.object(MasterDao, f'list_{resource}', new=list_mock), + patch.object(MasterDao, f'get_{resource}', new=detail_mock), ): - self.db.reset_mock() - with self.assertRaises(ServiceWarning): - await getattr(MasterService, f'remove_{resource}')(self.db, 1) - self.db.rollback.assert_awaited_once() - self.db.commit.assert_not_awaited() + listed = await getattr(MasterService, f'list_{resource}')( + self.db, + query, + self.scope, + is_page=True, + ) + detailed = await getattr(MasterService, f'detail_{resource}')(self.db, 7, self.scope) + self.assertIsInstance(listed, PageModel) + self.assertEqual(detail_model, detailed) + list_mock.assert_awaited_once_with(self.db, query, self.scope, True) + detail_mock.assert_awaited_once_with(self.db, 7, self.scope) - async def test_unreferenced_existing_records_are_deleted_for_all_resources(self) -> None: - for resource in RESOURCE_IDS: + async def test_test_scope_add_locks_batch_before_any_master_query_and_inherits_batch(self) -> None: + for resource, model in make_models().items(): + events: list[str] = [] + lock_batch = AsyncMock(side_effect=lambda *_args, _events=events: _events.append('lock-batch')) + find_duplicate = AsyncMock( + side_effect=lambda *_args, _events=events: _events.append('find-duplicate') + ) + add_record = AsyncMock(side_effect=lambda *_args, _events=events: _events.append('add-record')) + parent_method = None + parent_lookup = None + if resource == 'site': + parent_method = 'get_customer' + elif resource == 'line': + parent_method = 'get_site' with self.subTest(resource=resource), ExitStack() as stack: self.db.reset_mock() - count_mock = stack.enter_context( - patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=0)) + stack.enter_context( + patch.object(TestBatchAccessService, 'lock_active_batch', new=lock_batch) ) stack.enter_context( - patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=SimpleNamespace())) + patch.object(MasterDao, f'find_{resource}_by_code', new=find_duplicate) ) - delete_mock = stack.enter_context(patch.object(MasterDao, f'delete_{resource}', new=AsyncMock())) - - result = await getattr(MasterService, f'remove_{resource}')(self.db, 1) + stack.enter_context(patch.object(MasterDao, f'add_{resource}', new=add_record)) + if parent_method is not None: + parent_lookup = AsyncMock( + side_effect=lambda *_args, _events=events: ( + _events.append('get-parent') + or SimpleNamespace(status='0', test_batch_id=9) + ) + ) + stack.enter_context(patch.object(MasterDao, parent_method, new=parent_lookup)) + result = await getattr(MasterService, f'add_{resource}')( + self.db, + model, + self.admin, + self.scope, + ) self.assertTrue(result.is_success) - count_mock.assert_awaited_once_with(self.db, resource, 1) - delete_mock.assert_awaited_once_with(self.db, 1) - self.db.commit.assert_awaited_once() - self.db.rollback.assert_not_awaited() + self.assertEqual('lock-batch', events[0]) + lock_batch.assert_awaited_once_with(self.db, self.scope) + self.assertEqual(9, add_record.await_args.args[2]) + if parent_lookup is not None: + parent_lookup.assert_awaited_once() - async def test_remove_missing_record_is_explicit_for_all_resources(self) -> None: - for resource in RESOURCE_IDS: + async def test_test_scope_edit_locks_batch_before_business_rows_and_preserves_scope(self) -> None: + for resource, model in make_models(7).items(): + events: list[str] = [] + id_field = RESOURCE_IDS[resource] + current = SimpleNamespace(**{id_field: 7}, test_batch_id=9) + lock_batch = AsyncMock(side_effect=lambda *_args, _events=events: _events.append('lock-batch')) + get_current = AsyncMock( + side_effect=lambda *_args, _events=events, _current=current, **_kwargs: ( + _events.append('get-current') or _current + ) + ) + update_record = AsyncMock( + side_effect=lambda *_args, _events=events: _events.append('update-record') + ) with self.subTest(resource=resource), ExitStack() as stack: - stack.enter_context(patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=0))) - stack.enter_context(patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=None))) - with self.assertRaises(ServiceException): - await getattr(MasterService, f'remove_{resource}')(self.db, 1) + self.db.reset_mock() + stack.enter_context( + patch.object(TestBatchAccessService, 'lock_active_batch', new=lock_batch) + ) + stack.enter_context(patch.object(MasterDao, f'get_{resource}', new=get_current)) + stack.enter_context( + patch.object(MasterDao, f'find_{resource}_by_code', new=AsyncMock(return_value=current)) + ) + stack.enter_context(patch.object(MasterDao, f'update_{resource}', new=update_record)) + if resource == 'site': + stack.enter_context( + patch.object( + MasterDao, + 'get_customer', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=9)), + ) + ) + elif resource == 'line': + stack.enter_context( + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(status='0', test_batch_id=9)), + ) + ) - async def test_batch_remove_failure_rolls_back_without_delete_or_commit(self) -> None: - delete_model = DeleteStrainModel(strainIds=[1, 2]) + result = await getattr(MasterService, f'edit_{resource}')( + self.db, + model, + self.admin, + self.scope, + ) + self.assertTrue(result.is_success) + self.assertEqual('lock-batch', events[0]) + get_current.assert_awaited_once_with(self.db, 7, self.scope, for_update=True) + update_record.assert_awaited_once_with(self.db, model, self.scope) + + async def test_regular_user_cannot_add_or_edit_test_master_before_database_access(self) -> None: + current_user = make_current_user(8) + lock_batch = AsyncMock() + dao_call = AsyncMock() with ( - patch.object(MasterDao, 'count_references', new=AsyncMock(side_effect=[0, 1])), - patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=SimpleNamespace())), - patch.object(MasterDao, 'delete_strain', new=AsyncMock()) as delete_mock, - self.assertRaises(ServiceWarning), + patch.object(TestBatchAccessService, 'lock_active_batch', new=lock_batch), + patch.object(MasterDao, 'find_strain_by_code', new=dao_call), + patch.object(MasterDao, 'get_strain', new=dao_call), ): - await MasterService.remove_strain(self.db, delete_model) + with self.assertRaisesRegex(PermissionException, '仅超级管理员'): + await MasterService.add_strain( + self.db, + make_models()['strain'], + current_user, + self.scope, + ) + with self.assertRaisesRegex(PermissionException, '仅超级管理员'): + await MasterService.edit_strain( + self.db, + make_models(7)['strain'], + current_user, + self.scope, + ) - delete_mock.assert_not_awaited() - self.db.commit.assert_not_awaited() - self.db.rollback.assert_awaited_once() + lock_batch.assert_not_awaited() + dao_call.assert_not_awaited() + + async def test_closed_batch_rejection_happens_before_unique_parent_or_business_queries(self) -> None: + closed = PermissionException('', '当前测试批次已关闭') + dao_call = AsyncMock() + with ( + patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=closed)), + patch.object(MasterDao, 'get_customer', new=dao_call), + patch.object(MasterDao, 'find_site_by_code', new=dao_call), + patch.object(MasterDao, 'add_site', new=dao_call), + self.assertRaisesRegex(PermissionException, '已关闭'), + ): + await MasterService.add_site( + self.db, + make_models()['site'], + self.admin, + self.scope, + ) + + dao_call.assert_not_awaited() + + async def test_parent_relations_reject_formal_test_and_cross_batch_mixing(self) -> None: + cases = ( + ('site', 'get_customer', TestBatchScope.formal(), 9), + ('site', 'get_customer', self.scope, None), + ('site', 'get_customer', self.scope, 10), + ('line', 'get_site', TestBatchScope.formal(), 9), + ('line', 'get_site', self.scope, None), + ('line', 'get_site', self.scope, 10), + ) + for resource, parent_method, scope, parent_batch_id in cases: + with self.subTest(resource=resource, scope=scope, parent_batch_id=parent_batch_id), ExitStack() as stack: + stack.enter_context( + patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock()) + ) + stack.enter_context( + patch.object( + MasterDao, + parent_method, + new=AsyncMock( + return_value=SimpleNamespace(status='0', test_batch_id=parent_batch_id) + ), + ) + ) + stack.enter_context( + patch.object(MasterDao, f'find_{resource}_by_code', new=AsyncMock(return_value=None)) + ) + stack.enter_context(patch.object(MasterDao, f'add_{resource}', new=AsyncMock())) + with self.assertRaisesRegex(ServiceException, '不属于当前数据模式或测试批次'): + await getattr(MasterService, f'add_{resource}')( + self.db, + make_models()[resource], + self.admin, + scope, + ) + + async def test_all_legacy_master_deletes_are_rejected_before_delete_dao(self) -> None: + query_result = MagicMock() + query_result.scalar.return_value = 0 + query_result.scalar_one_or_none.return_value = SimpleNamespace() + self.db.execute = AsyncMock(return_value=query_result) + + for resource in RESOURCE_IDS: + delete_dao = AsyncMock() + with self.subTest(resource=resource): + with ( + patch.object(MasterDao, f'delete_{resource}', new=delete_dao, create=True), + self.assertRaisesRegex(ServiceWarning, '不支持单条物理删除,请通过测试数据批次清理'), + ): + await getattr(MasterService, f'remove_{resource}')(self.db, 1) + delete_dao.assert_not_awaited() + self.db.commit.assert_not_awaited() async def test_add_ignores_client_primary_key_before_calling_dao(self) -> None: model = make_models(77)['strain'] @@ -423,13 +575,14 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None)), patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_mock, ): - await MasterService.add_strain(self.db, model) + await MasterService.add_strain(self.db, model, self.admin, self.formal_scope) persisted_model = add_mock.await_args.args[1] self.assertIsNone(persisted_model.strain_id) + self.assertIsNone(add_mock.await_args.args[2]) - async def test_integrity_errors_from_add_update_delete_and_commit_are_stable(self) -> None: - for phase in ('add', 'update', 'delete', 'commit'): + async def test_integrity_errors_from_add_update_and_commit_are_stable(self) -> None: + for phase in ('add', 'update', 'commit'): db = MagicMock(spec=AsyncSession) with self.subTest(phase=phase), ExitStack() as stack: if phase in {'add', 'commit'}: @@ -445,13 +598,20 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): ) if phase == 'commit': db.commit.side_effect = integrity_error() - call = MasterService.add_strain(db, make_models()['strain']) - elif phase == 'update': + call = MasterService.add_strain( + db, + make_models()['strain'], + self.admin, + self.formal_scope, + ) + else: stack.enter_context( patch.object( MasterDao, 'get_strain', - new=AsyncMock(return_value=SimpleNamespace(strain_id=7)), + new=AsyncMock( + return_value=SimpleNamespace(strain_id=7, test_batch_id=None) + ), ) ) stack.enter_context( @@ -460,16 +620,12 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): stack.enter_context( patch.object(MasterDao, 'update_strain', new=AsyncMock(side_effect=integrity_error())) ) - call = MasterService.edit_strain(db, make_models(7)['strain']) - else: - stack.enter_context(patch.object(MasterDao, 'count_references', new=AsyncMock(return_value=0))) - stack.enter_context( - patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=SimpleNamespace())) + call = MasterService.edit_strain( + db, + make_models(7)['strain'], + self.admin, + self.formal_scope, ) - stack.enter_context( - patch.object(MasterDao, 'delete_strain', new=AsyncMock(side_effect=integrity_error())) - ) - call = MasterService.remove_strain(db, 7) with self.assertRaises(ServiceException) as raised: await call @@ -487,7 +643,12 @@ class TestMasterService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'add_strain', new=AsyncMock(side_effect=error)), self.assertRaises(RuntimeError) as raised, ): - await MasterService.add_strain(self.db, make_models()['strain']) + await MasterService.add_strain( + self.db, + make_models()['strain'], + self.admin, + self.formal_scope, + ) self.assertIs(error, raised.exception) self.db.rollback.assert_awaited_once() @@ -561,7 +722,12 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): self.subTest(resource=resource), patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=page)) as paginate_mock, ): - result = await getattr(MasterDao, f'list_{resource}')(self.db, query_model, is_page=True) + result = await getattr(MasterDao, f'list_{resource}')( + self.db, + query_model, + TestBatchScope.formal(), + is_page=True, + ) statement = paginate_mock.await_args.args[1] sql = compile_sql(statement) self.assertIs(page, result) @@ -580,6 +746,7 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): result = await MasterDao.list_strain( self.db, StrainPageQueryModel(strainCode=strain_code, pageNum=1, pageSize=10), + TestBatchScope.formal(), is_page=True, ) @@ -600,7 +767,11 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = None self.db.execute = AsyncMock(return_value=result) - await getattr(MasterDao, f'get_{resource}')(self.db, 9) + await getattr(MasterDao, f'get_{resource}')( + self.db, + 9, + TestBatchScope.formal(), + ) sql = compile_sql(self.db.execute.await_args.args[0]) self.assertIn(f'FROM {table}', sql) @@ -618,7 +789,12 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = None self.db.execute = AsyncMock(return_value=result) - await getattr(MasterDao, f'get_{resource}')(self.db, 9, for_update=True) + await getattr(MasterDao, f'get_{resource}')( + self.db, + 9, + TestBatchScope.formal(), + for_update=True, + ) statement = self.db.execute.await_args.args[0] self.assertIn('FOR UPDATE', compile_sql(statement)) @@ -639,8 +815,10 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): await getattr(MasterDao, f'find_{resource}_by_code')(self.db, *args) - sql = compile_sql(self.db.execute.await_args.args[0]) + statement = self.db.execute.await_args.args[0] + sql = compile_sql(statement) self.assertIn(f'FROM {table}', sql) + self.assertNotIn('test_batch_id', compile_sql(statement.whereclause)) for predicate in predicates: self.assertIn(f'{table}.{predicate} =', sql) @@ -655,43 +833,17 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): for resource, model in make_models(77).items(): with self.subTest(resource=resource): self.db.reset_mock() - record = await getattr(MasterDao, f'add_{resource}')(self.db, model) + record = await getattr(MasterDao, f'add_{resource}')(self.db, model, None) self.assertIsInstance(record, expected_classes[resource]) self.assertIsNone(getattr(record, RESOURCE_IDS[resource])) + self.assertIsNone(record.test_batch_id) self.db.add.assert_called_once_with(record) self.db.flush.assert_awaited_once() self.db.commit.assert_not_awaited() self.db.rollback.assert_not_awaited() - async def test_add_and_update_exclude_read_only_removability_from_orm_payloads(self) -> None: - table_names = { - 'strain': 'biz_strain', - 'customer': 'biz_customer', - 'site': 'biz_customer_site', - 'line': 'biz_production_line', - 'warehouse': 'biz_warehouse', - } - for resource, base_model in make_models(7).items(): - payload = base_model.model_dump(by_alias=True) - payload['canRemove'] = False - try: - model = type(base_model).model_validate(payload) - except ValueError as exc: - self.fail(f'{resource} 缺少只读 canRemove 契约:{exc}') - - with self.subTest(resource=resource): - self.db.reset_mock() - record = await getattr(MasterDao, f'add_{resource}')(self.db, model) - self.assertNotIn('can_remove', record.__dict__) - - self.db.reset_mock() - await getattr(MasterDao, f'update_{resource}')(self.db, model) - update_sql = compile_sql(self.db.execute.await_args.args[0]) - self.assertIn(f'UPDATE {table_names[resource]} SET', update_sql) - self.assertNotIn('can_remove', update_sql) - - async def test_update_and_delete_statements_use_primary_key_and_preserve_creation_audit(self) -> None: + async def test_update_statements_use_primary_key_scope_and_preserve_creation_audit(self) -> None: table_names = { 'strain': 'biz_strain', 'customer': 'biz_customer', @@ -706,144 +858,93 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): model.update_by = 'server-user' with self.subTest(resource=resource): self.db.reset_mock() - await getattr(MasterDao, f'update_{resource}')(self.db, model) + await getattr(MasterDao, f'update_{resource}')( + self.db, + model, + TestBatchScope.formal(), + ) update_sql = compile_sql(self.db.execute.await_args.args[0]) table = table_names[resource] id_field = RESOURCE_IDS[resource] self.assertIn(f'UPDATE {table} SET', update_sql) self.assertIn(f'WHERE {table}.{id_field} = 7', update_sql) + self.assertIn(f'{table}.test_batch_id IS NULL', update_sql) self.assertNotIn('create_by', update_sql) self.assertNotIn('create_time', update_sql) self.db.commit.assert_not_awaited() - self.db.reset_mock() - await getattr(MasterDao, f'delete_{resource}')(self.db, 7) - delete_sql = compile_sql(self.db.execute.await_args.args[0]) - self.assertIn(f'DELETE FROM {table}', delete_sql) - self.assertIn(f'{table}.{id_field} = 7', delete_sql) - self.db.commit.assert_not_awaited() +class TestMasterDaoBatchScope(unittest.IsolatedAsyncioTestCase): + TABLES = { + 'strain': ('biz_strain', 'strain_id'), + 'customer': ('biz_customer', 'customer_id'), + 'site': ('biz_customer_site', 'site_id'), + 'line': ('biz_production_line', 'line_id'), + 'warehouse': ('biz_warehouse', 'warehouse_id'), + } - async def test_count_references_covers_every_direct_foreign_key(self) -> None: - expected = { - 'strain': (('biz_filling_record', ('strain_id',)),), - 'customer': (('biz_customer_site', ('customer_id',)),), - 'site': ( - ('biz_production_line', ('site_id',)), - ('biz_user_site', ('site_id',)), - ('biz_shipment_order', ('target_site_id',)), - ('biz_usage_record', ('site_id',)), - ('biz_return_order', ('source_site_id',)), - ('biz_pallet_inventory', ('site_id',)), - ('biz_pallet_event', ('before_site_id', 'after_site_id')), - ), - 'line': ( - ('biz_usage_record', ('line_id',)), - ('biz_pallet_inventory', ('current_line_id',)), - ('biz_pallet_event', ('before_line_id', 'after_line_id')), - ), - 'warehouse': ( - ('biz_user_warehouse', ('warehouse_id',)), - ('biz_inbound_order', ('warehouse_id',)), - ('biz_filling_record', ('warehouse_id',)), - ('biz_shipment_order', ('source_warehouse_id',)), - ('biz_return_order', ('target_warehouse_id',)), - ('biz_pallet_inventory', ('warehouse_id',)), - ('biz_pallet_event', ('before_warehouse_id', 'after_warehouse_id')), - ), + def setUp(self) -> None: + self.db = MagicMock(spec=AsyncSession) + + async def test_all_master_lists_apply_formal_and_test_scope_in_sql(self) -> None: + queries = { + 'strain': StrainPageQueryModel(), + 'customer': CustomerPageQueryModel(), + 'site': SitePageQueryModel(), + 'line': LinePageQueryModel(), + 'warehouse': WarehousePageQueryModel(), } - for resource, references in expected.items(): - results = [] - for _ in references: - result = MagicMock() - result.scalar.return_value = 0 - results.append(result) - self.db.execute = AsyncMock(side_effect=results) - - count = await MasterDao.count_references(self.db, resource, 9) - - self.assertEqual(0, count) - self.assertEqual(len(references), self.db.execute.await_count) - for call, (table, columns) in zip(self.db.execute.await_args_list, references, strict=True): - sql = compile_sql(call.args[0]) - self.assertIn(f'FROM {table}', sql) - for column in columns: - self.assertIn(f'{table}.{column} = 9', sql) - self.db.commit.assert_not_awaited() - - async def test_master_removability_uses_the_same_reference_constraints_as_delete(self) -> None: - expected = { - 'strain': ('biz_strain', 'strain_id', (('biz_filling_record', ('strain_id',)),)), - 'customer': ('biz_customer', 'customer_id', (('biz_customer_site', ('customer_id',)),)), - 'site': ( - 'biz_customer_site', - 'site_id', - ( - ('biz_production_line', ('site_id',)), - ('biz_user_site', ('site_id',)), - ('biz_shipment_order', ('target_site_id',)), - ('biz_usage_record', ('site_id',)), - ('biz_return_order', ('source_site_id',)), - ('biz_pallet_inventory', ('site_id',)), - ('biz_pallet_event', ('before_site_id', 'after_site_id')), - ), - ), - 'line': ( - 'biz_production_line', - 'line_id', - ( - ('biz_usage_record', ('line_id',)), - ('biz_pallet_inventory', ('current_line_id',)), - ('biz_pallet_event', ('before_line_id', 'after_line_id')), - ), - ), - 'warehouse': ( - 'biz_warehouse', - 'warehouse_id', - ( - ('biz_user_warehouse', ('warehouse_id',)), - ('biz_inbound_order', ('warehouse_id',)), - ('biz_filling_record', ('warehouse_id',)), - ('biz_shipment_order', ('source_warehouse_id',)), - ('biz_return_order', ('target_warehouse_id',)), - ('biz_pallet_inventory', ('warehouse_id',)), - ('biz_pallet_event', ('before_warehouse_id', 'after_warehouse_id')), - ), - ), - } - method = getattr(MasterDao, 'get_master_removability', None) - self.assertIsNotNone(method) - - for resource, (outer_table, outer_id, references) in expected.items(): - result = MagicMock() - result.all.return_value = [(1, True), (2, False)] - self.db.execute = AsyncMock(return_value=result) - - removability = await method(self.db, resource, [1, 2, 3]) - - self.assertEqual({1: True, 2: False, 3: False}, removability) - statement = self.db.execute.await_args.args[0] - for dialect in (mysql.dialect(), postgresql.dialect()): - with self.subTest(resource=resource, dialect=dialect.name): - sql = ' '.join( - str(statement.compile(dialect=dialect, compile_kwargs={'literal_binds': True})).split() + for resource, (table, _) in self.TABLES.items(): + for scope, expected in ( + (TestBatchScope.formal(), f'{table}.test_batch_id IS NULL'), + (make_test_scope(9), f'{table}.test_batch_id = 9'), + ): + with ( + self.subTest(resource=resource, scope=scope.mode), + patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=[])) as paginate, + ): + await getattr(MasterDao, f'list_{resource}')( + self.db, + queries[resource], + scope, + is_page=False, ) - self.assertEqual(len(references), len(re.findall(r'NOT\s+\(?EXISTS\s+\(', sql))) - for table, columns in references: - self.assertRegex( - sql, - rf'NOT\s+\(?EXISTS\s+\(SELECT {table}\.{columns[0]} FROM {table} WHERE ', - ) - for column in columns: - self.assertIn(f'{table}.{column} = {outer_table}.{outer_id}', sql) + self.assertIn(expected, compile_sql(paginate.await_args.args[1])) - async def test_master_removability_short_circuits_empty_resource_ids(self) -> None: - self.db.execute = AsyncMock() + async def test_all_master_details_apply_scope_and_keep_locking(self) -> None: + query_result = MagicMock() + query_result.scalar_one_or_none.return_value = None + self.db.execute = AsyncMock(return_value=query_result) + for resource, (table, id_field) in self.TABLES.items(): + for scope, expected in ( + (TestBatchScope.formal(), f'{table}.test_batch_id IS NULL'), + (make_test_scope(9), f'{table}.test_batch_id = 9'), + ): + with self.subTest(resource=resource, scope=scope.mode): + self.db.execute.reset_mock() + await getattr(MasterDao, f'get_{resource}')( + self.db, + 7, + scope, + for_update=True, + ) + sql = compile_sql(self.db.execute.await_args.args[0]) + self.assertIn(f'{table}.{id_field} = 7', sql) + self.assertIn(expected, sql) + self.assertIn('FOR UPDATE', sql) - for resource in RESOURCE_IDS: + async def test_add_receives_batch_id_separately_and_update_cannot_change_it(self) -> None: + table_by_resource = {resource: table for resource, (table, _) in self.TABLES.items()} + for resource, model in make_models(7).items(): with self.subTest(resource=resource): - self.assertEqual({}, await MasterDao.get_master_removability(self.db, resource, [])) + self.db.reset_mock() + record = await getattr(MasterDao, f'add_{resource}')(self.db, model, 9) + self.assertEqual(9, record.test_batch_id) - self.db.execute.assert_not_awaited() + self.db.reset_mock() + await getattr(MasterDao, f'update_{resource}')(self.db, model, make_test_scope(9)) + sql = compile_sql(self.db.execute.await_args.args[0]) + self.assertIn(f'{table_by_resource[resource]}.test_batch_id = 9', sql) + self.assertNotIn('SET test_batch_id=', sql) class TestMasterControllers(unittest.TestCase): @@ -952,6 +1053,24 @@ class TestMasterControllers(unittest.TestCase): else: self.assertEqual('ResponseBaseModel', route.response_model.__name__) + def test_get_and_write_routes_use_read_and_write_batch_scope_dependencies(self) -> None: + for router in self.ROUTERS.values(): + for route in router.routes: + if not isinstance(route, APIRoute): + continue + method = next(iter(route.methods)) + resolvers = [ + dependency.call + for dependency in route.dependant.dependencies + if isinstance(dependency.call, TestBatchScopeResolver) + ] + with self.subTest(path=route.path, method=method): + if method == 'DELETE': + self.assertEqual([], resolvers) + else: + self.assertEqual(1, len(resolvers)) + self.assertIs(method in {'POST', 'PUT'}, resolvers[0].write) + class TestDeleteModelCoverage(unittest.TestCase): def test_all_delete_models_are_used_by_service_contract(self) -> None: diff --git a/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py b/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py index 74da974..70a1c9d 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py @@ -10,14 +10,13 @@ from unittest.mock import call as mock_call from fastapi import FastAPI from fastapi.routing import APIRoute from pydantic import ValidationError -from sqlalchemy.dialects import mysql, postgresql from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from common.aspect.interface_auth import CheckUserInterfaceAuth from common.aspect.pre_auth import PreAuth from common.vo import CrudResponseModel, DataResponseModel, PageModel, PageResponseModel, ResponseBaseModel -from exceptions.exception import ServiceException +from exceptions.exception import PermissionException, ServiceException, ServiceWarning from module_admin.entity.do.user_do import SysUser from module_admin.service.login_service import LoginService from module_business.controller import pallet_controller as pallet_controller_module @@ -25,6 +24,8 @@ from module_business.controller import site_controller as site_controller_module from module_business.controller import warehouse_controller as warehouse_controller_module from module_business.dao.access_dao import AccessDao from module_business.dao.master_dao import MasterDao +from module_business.dependency.test_batch_dependency import TestBatchScopeResolver +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import ( BizCustomerSite, BizPallet, @@ -32,15 +33,7 @@ from module_business.entity.do.master_do import ( BizUserWarehouse, BizWarehouse, ) -from module_business.entity.do.operation_do import ( - BizFillingRecord, - BizInboundItem, - BizPalletEvent, - BizPalletInventory, - BizReturnItem, - BizShipmentItem, - BizUsageRecord, -) +from module_business.entity.do.operation_do import BizPalletInventory from module_business.entity.vo.master_vo import ( BusinessUserOptionModel, DeletePalletModel, @@ -52,6 +45,8 @@ from module_business.entity.vo.master_vo import ( ) from module_business.service import pallet_service as pallet_service_module from module_business.service.pallet_service import PalletService +from module_business.service.test_batch_access_service import TestBatchAccessService +from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope from utils.page_util import PageUtil @@ -63,9 +58,14 @@ def integrity_error() -> IntegrityError: return IntegrityError('statement', {}, RuntimeError('constraint')) -def make_pallet_record(pallet_id: int, capacity_packages: int = 20) -> SimpleNamespace: +def make_pallet_record( + pallet_id: int, + capacity_packages: int = 20, + test_batch_id: int | None = None, +) -> SimpleNamespace: return SimpleNamespace( pallet_id=pallet_id, + test_batch_id=test_batch_id, pallet_code=f'P-{pallet_id:03d}', qr_token=f'fixed-token-{pallet_id}', capacity_packages=capacity_packages, @@ -128,7 +128,12 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): expected = object() with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=expected)) as paginate: - actual = await MasterDao.list_pallet(self.db, query, is_page=True) + actual = await MasterDao.list_pallet( + self.db, + query, + TestBatchScope.formal(), + is_page=True, + ) self.assertIs(expected, actual) statement = paginate.await_args.args[1] @@ -138,57 +143,6 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): self.assertIn("biz_pallet.asset_status = '0'", sql) self.assertIn('ORDER BY biz_pallet.pallet_id', sql) - async def test_pallet_removability_uses_one_complete_business_reference_query(self) -> None: - query_result = MagicMock() - query_result.all.return_value = [(2, True), (3, False)] - self.db.execute.return_value = query_result - - actual = await MasterDao.get_pallet_removability(self.db, [1, 2, 3]) - - business_reference_columns = ( - BizInboundItem.pallet_id, - BizFillingRecord.pallet_id, - BizShipmentItem.pallet_id, - BizUsageRecord.pallet_id, - BizReturnItem.pallet_id, - BizPalletEvent.pallet_id, - ) - expected_references = {(column.class_.__tablename__, column.key) for column in business_reference_columns} - metadata_references = { - (table.name, column.name) - for table in BizPallet.metadata.tables.values() - if table.name != BizPalletInventory.__tablename__ - for column in table.columns - if any(foreign_key.target_fullname == 'biz_pallet.pallet_id' for foreign_key in column.foreign_keys) - } - self.assertEqual(expected_references, metadata_references) - self.assertEqual({1: False, 2: True, 3: False}, actual) - self.db.execute.assert_awaited_once() - - statement = self.db.execute.await_args.args[0] - for dialect in (mysql.dialect(), postgresql.dialect()): - with self.subTest(dialect=dialect.name): - sql = ' '.join(str(statement.compile(dialect=dialect, compile_kwargs={'literal_binds': True})).split()) - self.assertIn('FROM biz_pallet', sql) - self.assertIn( - 'EXISTS (SELECT biz_pallet_inventory.pallet_id FROM biz_pallet_inventory ' - 'WHERE biz_pallet_inventory.pallet_id = biz_pallet.pallet_id ' - "AND biz_pallet_inventory.flow_state = 'UNSTOCKED')", - sql, - ) - self.assertEqual(6, sql.count('NOT (EXISTS')) - for table_name, _ in expected_references: - self.assertIn( - f'NOT (EXISTS (SELECT {table_name}.pallet_id FROM {table_name} ' - f'WHERE {table_name}.pallet_id = biz_pallet.pallet_id))', - sql, - ) - - async def test_empty_pallet_removability_skips_database_query(self) -> None: - self.assertEqual({}, await MasterDao.get_pallet_removability(self.db, [])) - - self.db.execute.assert_not_awaited() - async def test_add_pallet_and_inventory_only_flush_current_transaction(self) -> None: pallet = BizPallet( pallet_code='P-001', @@ -212,11 +166,29 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): self.db.commit.assert_not_awaited() self.db.rollback.assert_not_awaited() + async def test_pallet_code_lookup_keeps_global_uniqueness(self) -> None: + query_result = MagicMock() + query_result.scalar_one_or_none.return_value = None + self.db.execute = AsyncMock(return_value=query_result) + + await MasterDao.find_pallet_by_code(self.db, 'P-001') + + statement = self.db.execute.await_args.args[0] + sql = compile_sql(statement) + self.assertIn("biz_pallet.pallet_code = 'P-001'", sql) + self.assertNotIn('test_batch_id', compile_sql(statement.whereclause)) + async def test_update_pallet_sql_never_contains_identity_fields(self) -> None: edit = PalletEditModel(palletId=3, capacityPackages=18, assetStatus='1', remark='停用') updated_at = datetime(2026, 7, 26, 10, 30) - await MasterDao.update_pallet(self.db, edit, update_by='admin', update_time=updated_at) + await MasterDao.update_pallet( + self.db, + edit, + TestBatchScope.formal(), + update_by='admin', + update_time=updated_at, + ) statement = self.db.execute.await_args.args[0] sql = compile_sql(statement) @@ -231,31 +203,18 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): self.db.commit.assert_not_awaited() self.db.rollback.assert_not_awaited() - async def test_inventory_delete_and_event_count_target_one_pallet(self) -> None: - count_result = MagicMock() - count_result.scalar.return_value = 2 - self.db.execute.side_effect = [MagicMock(), count_result] - - await MasterDao.delete_pallet_inventory(self.db, pallet_id=9) - count = await MasterDao.count_pallet_events(self.db, pallet_id=9) - - delete_statement = self.db.execute.await_args_list[0].args[0] - self.assertTrue(delete_statement.table.compare(BizPalletInventory.__table__)) - self.assertIn('biz_pallet_inventory.pallet_id = 9', compile_sql(delete_statement)) - count_statement = self.db.execute.await_args_list[1].args[0] - self.assertIn('FROM biz_pallet_event', compile_sql(count_statement)) - self.assertIn('biz_pallet_event.pallet_id = 9', compile_sql(count_statement)) - self.assertEqual(2, count) - self.db.commit.assert_not_awaited() - self.db.rollback.assert_not_awaited() - async def test_inventory_write_read_uses_row_lock(self) -> None: result = MagicMock() inventory = SimpleNamespace(pallet_id=9) result.scalar_one_or_none.return_value = inventory self.db.execute.return_value = result - actual = await MasterDao.get_pallet_inventory(self.db, pallet_id=9, for_update=True) + actual = await MasterDao.get_pallet_inventory( + self.db, + pallet_id=9, + scope=TestBatchScope.formal(), + for_update=True, + ) self.assertIs(inventory, actual) statement = self.db.execute.await_args.args[0] @@ -268,7 +227,12 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): result.scalar_one_or_none.return_value = pallet self.db.execute.return_value = result - actual = await MasterDao.get_pallet(self.db, pallet_id=9, for_update=True) + actual = await MasterDao.get_pallet( + self.db, + pallet_id=9, + scope=TestBatchScope.formal(), + for_update=True, + ) self.assertIs(pallet, actual) statement = self.db.execute.await_args.args[0] @@ -288,7 +252,12 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): query_result.scalar_one_or_none.return_value = record self.db.execute.return_value = query_result - actual = await method(self.db, resource_id, for_update=True) + actual = await method( + self.db, + resource_id, + TestBatchScope.formal(), + for_update=True, + ) self.assertIs(record, actual) statement = self.db.execute.await_args.args[0] @@ -309,7 +278,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): query_result.scalar_one_or_none.return_value = SimpleNamespace() self.db.execute.return_value = query_result - await method(self.db, resource_id) + await method(self.db, resource_id, TestBatchScope.formal()) statement = self.db.execute.await_args.args[0] self.assertTrue(statement.get_final_froms()[0].compare(model.__table__)) @@ -322,6 +291,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): BizUserWarehouse, BizUserWarehouse.user_id, 'warehouse_id', + BizWarehouse, 7, ), ( @@ -329,27 +299,33 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): BizUserSite, BizUserSite.user_id, 'site_id', + BizCustomerSite, 9, ), ) - for method, model, selected_column, id_field, resource_id in cases: + for method, model, selected_column, id_field, scope_model, resource_id in cases: with self.subTest(resource=model.__tablename__): self.db.reset_mock() query_result = MagicMock() query_result.scalars.return_value.all.return_value = [2, 5] self.db.execute.return_value = query_result - actual = await method(self.db, resource_id) + actual = await method(self.db, resource_id, TestBatchScope.formal()) self.assertEqual([2, 5], actual) self.db.execute.assert_awaited_once() statement = self.db.execute.await_args.args[0] self.assertEqual([selected_column], list(statement.selected_columns)) - self.assertTrue(statement.get_final_froms()[0].compare(model.__table__)) + sql = compile_sql(statement) + self.assertIn( + f'FROM {model.__tablename__} JOIN {scope_model.__tablename__}', + sql, + ) self.assertIn( f'{model.__tablename__}.{id_field} = {resource_id}', - compile_sql(statement), + sql, ) + self.assertIn(f'{scope_model.__tablename__}.test_batch_id IS NULL', sql) async def test_assignable_user_queries_filter_disabled_and_deleted_rows(self) -> None: list_result = MagicMock() @@ -379,6 +355,8 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase): class TestPalletCreationService(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) + self.admin = make_admin() + self.scope = TestBatchScope.formal() self.create = PalletCreateModel(palletCode='P-001', capacityPackages=20, remark='测试托盘') self.fixed_time = datetime(2026, 7, 26, 11, 30) @@ -409,7 +387,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase): patch.object(pallet_service_module, 'datetime', create=True) as service_datetime, ): service_datetime.now.return_value = self.fixed_time - result = await PalletService.add_pallet(self.db, self.create, current_user_name='admin') + result = await PalletService.add_pallet(self.db, self.create, self.admin, self.scope) token_urlsafe.assert_called_once_with(32) pallet = captured['pallet'] @@ -421,11 +399,13 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase): self.assertEqual(self.fixed_time, pallet.create_time) self.assertEqual('admin', pallet.update_by) self.assertEqual(self.fixed_time, pallet.update_time) + self.assertIsNone(pallet.test_batch_id) inventory = captured['inventory'] self.assertEqual(17, inventory.pallet_id) self.assertEqual('UNSTOCKED', inventory.flow_state) self.assertEqual(0, inventory.remaining_packages) self.assertEqual(0, inventory.version) + self.assertIsNone(inventory.test_batch_id) self.assertEqual(['pallet', 'inventory', 'commit'], actions) self.db.commit.assert_awaited_once() self.db.rollback.assert_not_awaited() @@ -441,7 +421,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'add_pallet_inventory', new=add_inventory), self.assertRaisesRegex(RuntimeError, 'flush failed'), ): - await PalletService.add_pallet(self.db, self.create, current_user_name='admin') + await PalletService.add_pallet(self.db, self.create, self.admin, self.scope) add_inventory.assert_not_awaited() self.db.commit.assert_not_awaited() @@ -459,7 +439,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'add_pallet_inventory', new=AsyncMock()), self.assertRaisesRegex(RuntimeError, 'commit failed'), ): - await PalletService.add_pallet(self.db, self.create, current_user_name='admin') + await PalletService.add_pallet(self.db, self.create, self.admin, self.scope) self.db.rollback.assert_awaited_once() @@ -469,7 +449,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=integrity_error())), self.assertRaises(ServiceException) as raised, ): - await PalletService.add_pallet(self.db, self.create, current_user_name='admin') + await PalletService.add_pallet(self.db, self.create, self.admin, self.scope) self.assertEqual('新增托盘失败,托盘编号或二维码标识已存在', raised.exception.message) self.db.rollback.assert_awaited_once() @@ -482,7 +462,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'add_pallet', new=add_pallet), self.assertRaises(ServiceException) as raised, ): - await PalletService.add_pallet(self.db, self.create, current_user_name='admin') + await PalletService.add_pallet(self.db, self.create, self.admin, self.scope) self.assertEqual('托盘编号已存在', raised.exception.message) add_pallet.assert_not_awaited() @@ -499,28 +479,28 @@ class TestPalletQrService(unittest.IsolatedAsyncioTestCase): async def test_get_qrcode_uses_original_persisted_token(self) -> None: db = MagicMock(spec=AsyncSession) + scope = TestBatchScope.formal() + get_pallet = AsyncMock(return_value=SimpleNamespace(qr_token='persisted-token', test_batch_id=None)) with ( - patch.object( - MasterDao, - 'get_pallet', - new=AsyncMock(return_value=SimpleNamespace(qr_token='persisted-token')), - ), + patch.object(MasterDao, 'get_pallet', new=get_pallet), patch.object(PalletService, 'render_qr_png', return_value=b'png-bytes') as render, patch.object(secrets, 'token_urlsafe') as token_urlsafe, ): - actual = await PalletService.get_pallet_qrcode(db, pallet_id=7) + actual = await PalletService.get_pallet_qrcode(db, pallet_id=7, scope=scope) self.assertEqual(b'png-bytes', actual) + get_pallet.assert_awaited_once_with(db, 7, scope) render.assert_called_once_with('persisted-token') token_urlsafe.assert_not_called() async def test_get_qrcode_rejects_missing_pallet(self) -> None: db = MagicMock(spec=AsyncSession) + scope = TestBatchScope.formal() with ( patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException) as raised, ): - await PalletService.get_pallet_qrcode(db, pallet_id=404) + await PalletService.get_pallet_qrcode(db, pallet_id=404, scope=scope) self.assertEqual('托盘不存在', raised.exception.message) @@ -528,8 +508,10 @@ class TestPalletQrService(unittest.IsolatedAsyncioTestCase): class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) + self.admin = make_admin() + self.scope = TestBatchScope.formal() - async def test_list_computes_can_remove_with_one_bulk_query(self) -> None: + async def test_list_returns_models_without_removability_lookup(self) -> None: page = PageModel[Any]( rows=[make_pallet_record(pallet_id) for pallet_id in range(1, 1001)], pageNum=1, @@ -539,53 +521,30 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase): ) query = PalletPageQueryModel() list_pallet = AsyncMock(return_value=page) - removability = AsyncMock(return_value={pallet_id: pallet_id % 2 == 1 for pallet_id in range(1, 1001)}) - get_inventory = AsyncMock(return_value=SimpleNamespace(flow_state='UNSTOCKED')) - count_events = AsyncMock(return_value=0) - with ( - patch.object(MasterDao, 'list_pallet', new=list_pallet), - patch.object(MasterDao, 'get_pallet_removability', new=removability, create=True), - patch.object(MasterDao, 'get_pallet_inventory', new=get_inventory), - patch.object(MasterDao, 'count_pallet_events', new=count_events), - ): - actual = await PalletService.list_pallet(self.db, query, is_page=True) + with patch.object(MasterDao, 'list_pallet', new=list_pallet): + actual = await PalletService.list_pallet(self.db, query, self.scope, is_page=True) self.assertIsInstance(actual, PageModel) self.assertEqual(1000, len(actual.rows)) self.assertTrue(all(isinstance(row, PalletModel) for row in actual.rows)) - self.assertEqual( - [pallet_id % 2 == 1 for pallet_id in range(1, 1001)], - [row.can_remove for row in actual.rows], - ) - list_pallet.assert_awaited_once_with(self.db, query, True) - removability.assert_awaited_once_with(self.db, list(range(1, 1001))) - get_inventory.assert_not_awaited() - count_events.assert_not_awaited() + self.assertNotIn('can_remove', PalletModel.model_fields) + list_pallet.assert_awaited_once_with(self.db, query, self.scope, True) - async def test_detail_computes_false_when_inventory_is_not_unstocked(self) -> None: - removability = AsyncMock(return_value={3: False}) - get_inventory = AsyncMock(return_value=SimpleNamespace(flow_state='EMPTY_IN_WAREHOUSE')) - count_events = AsyncMock(return_value=0) - with ( - patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))), - patch.object(MasterDao, 'get_pallet_removability', new=removability, create=True), - patch.object(MasterDao, 'get_pallet_inventory', new=get_inventory), - patch.object(MasterDao, 'count_pallet_events', new=count_events), - ): - actual = await PalletService.detail_pallet(self.db, pallet_id=3) + async def test_detail_returns_persisted_pallet_without_removability_lookup(self) -> None: + get_pallet = AsyncMock(return_value=make_pallet_record(3)) + with patch.object(MasterDao, 'get_pallet', new=get_pallet): + actual = await PalletService.detail_pallet(self.db, pallet_id=3, scope=self.scope) self.assertIsInstance(actual, PalletModel) - self.assertFalse(actual.can_remove) - removability.assert_awaited_once_with(self.db, [3]) - get_inventory.assert_not_awaited() - count_events.assert_not_awaited() + self.assertNotIn('can_remove', PalletModel.model_fields) + get_pallet.assert_awaited_once_with(self.db, 3, self.scope) async def test_detail_rejects_missing_pallet(self) -> None: with ( patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException) as raised, ): - await PalletService.detail_pallet(self.db, pallet_id=404) + await PalletService.detail_pallet(self.db, pallet_id=404, scope=self.scope) self.assertEqual('托盘不存在', raised.exception.message) @@ -601,7 +560,7 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase): async def get_inventory(*_args: Any, **_kwargs: Any) -> SimpleNamespace: lock_actions.append('inventory') - return SimpleNamespace(remaining_packages=12) + return SimpleNamespace(remaining_packages=12, test_batch_id=None) get_pallet_mock = AsyncMock(side_effect=get_pallet) get_inventory_mock = AsyncMock(side_effect=get_inventory) @@ -612,17 +571,18 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase): patch.object(pallet_service_module, 'datetime') as service_datetime, ): service_datetime.now.return_value = updated_at - result = await PalletService.edit_pallet(self.db, edit, current_user_name='admin') + result = await PalletService.edit_pallet(self.db, edit, self.admin, self.scope) update_pallet.assert_awaited_once_with( self.db, edit, + self.scope, update_by='admin', update_time=updated_at, ) self.assertEqual(['inventory', 'pallet'], lock_actions) - get_pallet_mock.assert_awaited_once_with(self.db, 3, for_update=True) - get_inventory_mock.assert_awaited_once_with(self.db, 3, for_update=True) + get_pallet_mock.assert_awaited_once_with(self.db, 3, self.scope, for_update=True) + get_inventory_mock.assert_awaited_once_with(self.db, 3, self.scope, for_update=True) self.db.commit.assert_awaited_once() self.db.rollback.assert_not_awaited() self.assertTrue(result.is_success) @@ -635,12 +595,12 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase): patch.object( MasterDao, 'get_pallet_inventory', - new=AsyncMock(return_value=SimpleNamespace(remaining_packages=12)), + new=AsyncMock(return_value=SimpleNamespace(remaining_packages=12, test_batch_id=None)), ), patch.object(MasterDao, 'update_pallet', new=update_pallet), self.assertRaises(ServiceException) as raised, ): - await PalletService.edit_pallet(self.db, edit, current_user_name='admin') + await PalletService.edit_pallet(self.db, edit, self.admin, self.scope) self.assertEqual('托盘容量不能小于当前剩余包数', raised.exception.message) update_pallet.assert_not_awaited() @@ -654,195 +614,17 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)), self.assertRaises(ServiceException) as raised, ): - await PalletService.edit_pallet(self.db, edit, current_user_name='admin') + await PalletService.edit_pallet(self.db, edit, self.admin, self.scope) self.assertEqual('托盘库存不存在', raised.exception.message) self.db.rollback.assert_awaited_once() -class TestPalletRemovalService(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.db = MagicMock(spec=AsyncSession) - - async def test_batch_remove_preflights_all_then_deletes_inventory_before_pallet(self) -> None: - actions: list[str] = [] - - async def get_pallet( - _db: AsyncSession, - pallet_id: int, - *, - for_update: bool = False, - ) -> SimpleNamespace: - actions.append(f'get-pallet-{pallet_id}-{for_update}') - return make_pallet_record(pallet_id) - - async def get_inventory( - _db: AsyncSession, - pallet_id: int, - *, - for_update: bool = False, - ) -> SimpleNamespace: - actions.append(f'get-inventory-{pallet_id}-{for_update}') - return SimpleNamespace(flow_state='UNSTOCKED') - - async def count_events(_db: AsyncSession, pallet_id: int) -> int: - actions.append(f'count-events-{pallet_id}') - return 0 - - async def get_removability(_db: AsyncSession, pallet_ids: list[int]) -> dict[int, bool]: - actions.append(f'get-removability-{pallet_ids}') - return dict.fromkeys(pallet_ids, True) - - async def delete_inventory(_db: AsyncSession, pallet_id: int) -> None: - actions.append(f'delete-inventory-{pallet_id}') - - async def delete_pallet(_db: AsyncSession, pallet_id: int) -> None: - actions.append(f'delete-pallet-{pallet_id}') - - async def commit() -> None: - actions.append('commit') - - self.db.commit.side_effect = commit - with ( - patch.object(MasterDao, 'get_pallet', new=AsyncMock(side_effect=get_pallet)), - patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(side_effect=get_inventory)), - patch.object(MasterDao, 'count_pallet_events', new=AsyncMock(side_effect=count_events)), - patch.object( - MasterDao, - 'get_pallet_removability', - new=AsyncMock(side_effect=get_removability), - create=True, - ), - patch.object(MasterDao, 'delete_pallet_inventory', new=AsyncMock(side_effect=delete_inventory)), - patch.object(MasterDao, 'delete_pallet', new=AsyncMock(side_effect=delete_pallet)), - ): - result = await PalletService.remove_pallet(self.db, DeletePalletModel(palletIds=[5, 4])) - - self.assertEqual( - [ - 'get-inventory-4-True', - 'get-pallet-4-True', - 'get-inventory-5-True', - 'get-pallet-5-True', - 'get-removability-[4, 5]', - 'delete-inventory-4', - 'delete-pallet-4', - 'delete-inventory-5', - 'delete-pallet-5', - 'commit', - ], - actions, - ) - self.db.commit.assert_awaited_once() - self.db.rollback.assert_not_awaited() - self.assertTrue(result.is_success) - - async def test_remove_rejects_missing_pallet_or_inventory(self) -> None: - cases = ( - ('托盘不存在', None), - ('托盘库存不存在', make_pallet_record(4)), - ) - for message, pallet in cases: - with self.subTest(message=message): - self.db.reset_mock() - delete_inventory = AsyncMock() - delete_pallet = AsyncMock() - removability = AsyncMock() - with ( - patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=pallet)), - patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)), - patch.object( - MasterDao, - 'get_pallet_removability', - new=removability, - create=True, - ), - patch.object(MasterDao, 'delete_pallet_inventory', new=delete_inventory), - patch.object(MasterDao, 'delete_pallet', new=delete_pallet), - self.assertRaises(ServiceException) as raised, - ): - await PalletService.remove_pallet(self.db, 4) - - self.assertEqual(message, raised.exception.message) - removability.assert_not_awaited() - delete_inventory.assert_not_awaited() - delete_pallet.assert_not_awaited() - self.db.commit.assert_not_awaited() - self.db.rollback.assert_awaited_once() - - async def test_remove_rejects_any_ineligible_pallet_before_any_delete(self) -> None: - delete_inventory = AsyncMock() - delete_pallet = AsyncMock() - removability = AsyncMock(return_value={4: True, 5: False}) - with ( - patch.object( - MasterDao, - 'get_pallet', - new=AsyncMock(side_effect=[make_pallet_record(4), make_pallet_record(5)]), - ), - patch.object( - MasterDao, - 'get_pallet_inventory', - new=AsyncMock( - side_effect=[ - SimpleNamespace(flow_state='UNSTOCKED'), - SimpleNamespace(flow_state='UNSTOCKED'), - ] - ), - ), - patch.object(MasterDao, 'count_pallet_events', new=AsyncMock(return_value=0)), - patch.object( - MasterDao, - 'get_pallet_removability', - new=removability, - create=True, - ), - patch.object(MasterDao, 'delete_pallet_inventory', new=delete_inventory), - patch.object(MasterDao, 'delete_pallet', new=delete_pallet), - self.assertRaises(ServiceException) as raised, - ): - await PalletService.remove_pallet(self.db, DeletePalletModel(palletIds=[5, 4])) - - self.assertEqual('托盘状态或关联数据不允许删除', raised.exception.message) - removability.assert_awaited_once_with(self.db, [4, 5]) - delete_inventory.assert_not_awaited() - delete_pallet.assert_not_awaited() - self.db.commit.assert_not_awaited() - self.db.rollback.assert_awaited_once() - - async def test_remove_integrity_error_rolls_back_with_stable_failure(self) -> None: - removability = AsyncMock(return_value={4: True}) - count_events = AsyncMock(return_value=0) - with ( - patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(4))), - patch.object( - MasterDao, - 'get_pallet_inventory', - new=AsyncMock(return_value=SimpleNamespace(flow_state='UNSTOCKED')), - ), - patch.object(MasterDao, 'count_pallet_events', new=count_events), - patch.object( - MasterDao, - 'get_pallet_removability', - new=removability, - create=True, - ), - patch.object(MasterDao, 'delete_pallet_inventory', new=AsyncMock()), - patch.object(MasterDao, 'delete_pallet', new=AsyncMock(side_effect=integrity_error())), - self.assertRaises(ServiceException) as raised, - ): - await PalletService.remove_pallet(self.db, 4) - - self.assertEqual('删除托盘失败,托盘状态或关联数据已变化', raised.exception.message) - removability.assert_awaited_once_with(self.db, [4]) - count_events.assert_not_awaited() - self.db.rollback.assert_awaited_once() - self.db.commit.assert_not_awaited() - - class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = MagicMock(spec=AsyncSession) + self.admin = make_admin() + self.scope = TestBatchScope.formal() self.users = [ SimpleNamespace( user_id=2, @@ -865,7 +647,7 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): async def test_assignment_get_returns_only_safe_fields_with_assigned_marker(self) -> None: for resource, resource_id in (('warehouse', 7), ('site', 9)): with self.subTest(resource=resource): - get_resource = AsyncMock(return_value=SimpleNamespace()) + get_resource = AsyncMock(return_value=SimpleNamespace(test_batch_id=None)) list_users = AsyncMock(return_value=self.users) list_assigned_ids = AsyncMock(return_value=[2]) list_user_resource_ids = AsyncMock() @@ -884,7 +666,11 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): ), patch.object(AccessDao, f'list_user_{resource}_ids', new=list_user_resource_ids), ): - actual = await getattr(PalletService, f'list_{resource}_users')(self.db, resource_id) + actual = await getattr(PalletService, f'list_{resource}_users')( + self.db, + resource_id, + self.scope, + ) self.assertEqual( [ @@ -894,9 +680,9 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): [option.model_dump(by_alias=True) for option in actual], ) self.assertTrue(all(isinstance(option, BusinessUserOptionModel) for option in actual)) - get_resource.assert_awaited_once_with(self.db, resource_id) + get_resource.assert_awaited_once_with(self.db, resource_id, self.scope) list_users.assert_awaited_once_with(self.db) - list_assigned_ids.assert_awaited_once_with(self.db, resource_id) + list_assigned_ids.assert_awaited_once_with(self.db, resource_id, self.scope) list_user_resource_ids.assert_not_awaited() async def test_assignment_get_uses_three_fixed_queries_for_1000_users(self) -> None: @@ -906,7 +692,7 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): assigned_ids = [2, 500, 1000] for resource, resource_id in (('warehouse', 7), ('site', 9)): with self.subTest(resource=resource): - get_resource = AsyncMock(return_value=SimpleNamespace()) + get_resource = AsyncMock(return_value=SimpleNamespace(test_batch_id=None)) list_users = AsyncMock(return_value=users) list_assigned = AsyncMock(return_value=assigned_ids) list_user_resource_ids = AsyncMock() @@ -921,13 +707,17 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): ), patch.object(AccessDao, f'list_user_{resource}_ids', new=list_user_resource_ids), ): - actual = await getattr(PalletService, f'list_{resource}_users')(self.db, resource_id) + actual = await getattr(PalletService, f'list_{resource}_users')( + self.db, + resource_id, + self.scope, + ) self.assertEqual(1000, len(actual)) self.assertEqual(assigned_ids, [option.user_id for option in actual if option.assigned]) - get_resource.assert_awaited_once_with(self.db, resource_id) + get_resource.assert_awaited_once_with(self.db, resource_id, self.scope) list_users.assert_awaited_once_with(self.db) - list_assigned.assert_awaited_once_with(self.db, resource_id) + list_assigned.assert_awaited_once_with(self.db, resource_id, self.scope) list_user_resource_ids.assert_not_awaited() async def test_assignment_get_rejects_missing_resource_before_user_query(self) -> None: @@ -941,10 +731,14 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): patch.object(MasterDao, 'list_assignable_users', new=list_users), self.assertRaises(ServiceException) as raised, ): - await getattr(PalletService, f'list_{resource}_users')(self.db, resource_id) + await getattr(PalletService, f'list_{resource}_users')( + self.db, + resource_id, + self.scope, + ) self.assertEqual(message, raised.exception.message) - get_resource.assert_awaited_once_with(self.db, resource_id) + get_resource.assert_awaited_once_with(self.db, resource_id, self.scope) list_users.assert_not_awaited() async def test_assignment_put_validates_users_replaces_and_commits(self) -> None: @@ -953,7 +747,7 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): with self.subTest(resource=resource): self.db.reset_mock() calls = MagicMock() - get_resource_mock = AsyncMock(return_value=SimpleNamespace()) + get_resource_mock = AsyncMock(return_value=SimpleNamespace(test_batch_id=None)) list_users_mock = AsyncMock(return_value=self.users) replace_mock = AsyncMock() calls.attach_mock(get_resource_mock, 'get_resource') @@ -976,17 +770,29 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): self.db, resource_id, assignment, + self.admin, + self.scope, ) self.assertEqual( [ - mock_call.get_resource(self.db, resource_id, for_update=True), + mock_call.get_resource( + self.db, + resource_id, + self.scope, + for_update=True, + ), mock_call.list_users(self.db, [2, 5]), mock_call.replace(self.db, resource_id, [2, 5]), ], calls.mock_calls, ) - get_resource_mock.assert_awaited_once_with(self.db, resource_id, for_update=True) + get_resource_mock.assert_awaited_once_with( + self.db, + resource_id, + self.scope, + for_update=True, + ) list_users_mock.assert_awaited_once_with(self.db, [2, 5]) replace_mock.assert_awaited_once_with(self.db, resource_id, [2, 5]) self.db.commit.assert_awaited_once() @@ -1005,7 +811,11 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): list_users_mock = AsyncMock(return_value=[]) replace_mock = AsyncMock() with ( - patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=SimpleNamespace())), + patch.object( + MasterDao, + f'get_{resource}', + new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)), + ), patch.object(MasterDao, 'list_assignable_users_by_ids', new=list_users_mock), patch.object(AccessDao, f'replace_{resource}_users', new=replace_mock), ): @@ -1013,6 +823,8 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): self.db, resource_id, assignment, + self.admin, + self.scope, ) list_users_mock.assert_awaited_once_with(self.db, []) @@ -1042,7 +854,11 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): replace = AsyncMock() assignment = UserAssignModel(userIds=[2, 5]) with ( - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace())), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)), + ), patch.object( MasterDao, 'list_assignable_users_by_ids', @@ -1051,7 +867,13 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'replace_warehouse_users', new=replace), self.assertRaises(ServiceException) as raised, ): - await PalletService.assign_warehouse_users(self.db, 7, assignment) + await PalletService.assign_warehouse_users( + self.db, + 7, + assignment, + self.admin, + self.scope, + ) self.assertEqual('存在不存在、停用或已删除的用户', raised.exception.message) replace.assert_not_awaited() @@ -1061,7 +883,11 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): async def test_replace_failure_rolls_back_and_preserves_original_exception(self) -> None: assignment = UserAssignModel(userIds=[2, 5]) with ( - patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace())), + patch.object( + MasterDao, + 'get_site', + new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)), + ), patch.object( MasterDao, 'list_assignable_users_by_ids', @@ -1074,7 +900,13 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): ), self.assertRaisesRegex(RuntimeError, 'replace failed'), ): - await PalletService.assign_site_users(self.db, 9, assignment) + await PalletService.assign_site_users( + self.db, + 9, + assignment, + self.admin, + self.scope, + ) self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited_once() @@ -1083,7 +915,11 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): assignment = UserAssignModel(userIds=[2, 5]) self.db.commit.side_effect = RuntimeError('commit failed') with ( - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace())), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)), + ), patch.object( MasterDao, 'list_assignable_users_by_ids', @@ -1092,14 +928,24 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'replace_warehouse_users', new=AsyncMock()), self.assertRaisesRegex(RuntimeError, 'commit failed'), ): - await PalletService.assign_warehouse_users(self.db, 7, assignment) + await PalletService.assign_warehouse_users( + self.db, + 7, + assignment, + self.admin, + self.scope, + ) self.db.rollback.assert_awaited_once() async def test_assignment_integrity_error_has_stable_message_and_rolls_back(self) -> None: assignment = UserAssignModel(userIds=[2, 5]) with ( - patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace())), + patch.object( + MasterDao, + 'get_warehouse', + new=AsyncMock(return_value=SimpleNamespace(test_batch_id=None)), + ), patch.object( MasterDao, 'list_assignable_users_by_ids', @@ -1112,12 +958,271 @@ class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase): ), self.assertRaises(ServiceException) as raised, ): - await PalletService.assign_warehouse_users(self.db, 7, assignment) + await PalletService.assign_warehouse_users( + self.db, + 7, + assignment, + self.admin, + self.scope, + ) self.assertEqual('分配仓库用户失败,关联数据无效', raised.exception.message) self.db.rollback.assert_awaited_once() +class TestPalletBatchScopeService(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.db = MagicMock(spec=AsyncSession) + self.admin = make_admin() + self.scope = make_test_scope(9) + self.create = PalletCreateModel(palletCode='P-T09', capacityPackages=20) + + async def test_list_detail_and_qrcode_forward_scope_to_scoped_dao_reads(self) -> None: + query = PalletPageQueryModel() + page = PageModel(rows=[], pageNum=1, pageSize=10, total=0, hasNext=False) + list_pallet = AsyncMock(return_value=page) + get_pallet = AsyncMock(return_value=make_pallet_record(7, test_batch_id=9)) + with ( + patch.object(MasterDao, 'list_pallet', new=list_pallet), + patch.object(MasterDao, 'get_pallet', new=get_pallet), + patch.object(PalletService, 'render_qr_png', return_value=b'png'), + ): + listed = await PalletService.list_pallet(self.db, query, self.scope, is_page=True) + detailed = await PalletService.detail_pallet(self.db, 7, self.scope) + qr_png = await PalletService.get_pallet_qrcode(self.db, 7, self.scope) + + self.assertIsInstance(listed, PageModel) + self.assertIsInstance(detailed, PalletModel) + self.assertEqual(b'png', qr_png) + list_pallet.assert_awaited_once_with(self.db, query, self.scope, True) + self.assertEqual( + [mock_call(self.db, 7, self.scope), mock_call(self.db, 7, self.scope)], + get_pallet.await_args_list, + ) + + async def test_test_scope_add_locks_first_and_marks_pallet_and_inventory(self) -> None: + events: list[str] = [] + captured: dict[str, Any] = {} + + async def lock_batch(*_args: Any) -> None: + events.append('lock-batch') + + async def find_duplicate(*_args: Any) -> None: + events.append('find-duplicate') + + async def add_pallet(_db: AsyncSession, pallet: BizPallet) -> BizPallet: + events.append('add-pallet') + captured['pallet'] = pallet + pallet.pallet_id = 17 + return pallet + + async def add_inventory(_db: AsyncSession, inventory: BizPalletInventory) -> BizPalletInventory: + events.append('add-inventory') + captured['inventory'] = inventory + return inventory + + with ( + patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=lock_batch)), + patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(side_effect=find_duplicate)), + patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=add_pallet)), + patch.object(MasterDao, 'add_pallet_inventory', new=AsyncMock(side_effect=add_inventory)), + ): + result = await PalletService.add_pallet(self.db, self.create, self.admin, self.scope) + + self.assertTrue(result.is_success) + self.assertEqual('lock-batch', events[0]) + self.assertEqual(9, captured['pallet'].test_batch_id) + self.assertEqual(9, captured['inventory'].test_batch_id) + self.assertEqual(17, captured['inventory'].pallet_id) + + async def test_test_scope_edit_locks_batch_before_inventory_or_pallet(self) -> None: + events: list[str] = [] + + async def lock_batch(*_args: Any) -> None: + events.append('lock-batch') + + async def get_inventory(*_args: Any, **_kwargs: Any) -> SimpleNamespace: + events.append('get-inventory') + return SimpleNamespace(remaining_packages=10, test_batch_id=9) + + async def get_pallet(*_args: Any, **_kwargs: Any) -> SimpleNamespace: + events.append('get-pallet') + return make_pallet_record(7, test_batch_id=9) + + edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0') + update_pallet = AsyncMock() + with ( + patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=lock_batch)), + patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(side_effect=get_inventory)), + patch.object(MasterDao, 'get_pallet', new=AsyncMock(side_effect=get_pallet)), + patch.object(MasterDao, 'update_pallet', new=update_pallet), + ): + result = await PalletService.edit_pallet(self.db, edit, self.admin, self.scope) + + self.assertTrue(result.is_success) + self.assertEqual(['lock-batch', 'get-inventory', 'get-pallet'], events) + self.assertIs(self.scope, update_pallet.await_args.args[2]) + + async def test_regular_user_and_closed_batch_reject_pallet_writes_before_business_queries(self) -> None: + business_query = AsyncMock() + lock_batch = AsyncMock() + with ( + patch.object(TestBatchAccessService, 'lock_active_batch', new=lock_batch), + patch.object(MasterDao, 'find_pallet_by_code', new=business_query), + self.assertRaisesRegex(PermissionException, '仅超级管理员'), + ): + await PalletService.add_pallet(self.db, self.create, make_current_user(8), self.scope) + lock_batch.assert_not_awaited() + business_query.assert_not_awaited() + + closed = PermissionException('', '当前测试批次已关闭') + with ( + patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=closed)), + patch.object(MasterDao, 'find_pallet_by_code', new=business_query), + self.assertRaisesRegex(PermissionException, '已关闭'), + ): + await PalletService.add_pallet(self.db, self.create, self.admin, self.scope) + business_query.assert_not_awaited() + + async def test_assignment_reads_are_scoped_and_test_writes_lock_before_resource_and_users(self) -> None: + for resource, resource_id in (('warehouse', 7), ('site', 9)): + get_resource = AsyncMock(return_value=SimpleNamespace(test_batch_id=9)) + list_users = AsyncMock(return_value=[]) + list_assigned = AsyncMock(return_value=[]) + with ( + self.subTest(resource=resource, operation='read'), + patch.object(MasterDao, f'get_{resource}', new=get_resource), + patch.object(MasterDao, 'list_assignable_users', new=list_users), + patch.object(MasterDao, f'list_{resource}_assigned_user_ids', new=list_assigned), + ): + await getattr(PalletService, f'list_{resource}_users')( + self.db, + resource_id, + self.scope, + ) + get_resource.assert_awaited_once_with(self.db, resource_id, self.scope) + + events: list[str] = [] + lock_batch = AsyncMock(side_effect=lambda *_args, _events=events: _events.append('lock-batch')) + get_resource = AsyncMock( + side_effect=lambda *_args, _events=events, **_kwargs: ( + _events.append('get-resource') + or SimpleNamespace(test_batch_id=9) + ) + ) + list_users = AsyncMock( + side_effect=lambda *_args, _events=events: _events.append('list-users') or [] + ) + replace_users = AsyncMock( + side_effect=lambda *_args, _events=events: _events.append('replace-users') + ) + with ( + self.subTest(resource=resource, operation='write'), + patch.object(TestBatchAccessService, 'lock_active_batch', new=lock_batch), + patch.object(MasterDao, f'get_{resource}', new=get_resource), + patch.object(MasterDao, 'list_assignable_users_by_ids', new=list_users), + patch.object(AccessDao, f'replace_{resource}_users', new=replace_users), + ): + await getattr(PalletService, f'assign_{resource}_users')( + self.db, + resource_id, + UserAssignModel(userIds=[]), + self.admin, + self.scope, + ) + self.assertEqual('lock-batch', events[0]) + get_resource.assert_awaited_once_with( + self.db, + resource_id, + self.scope, + for_update=True, + ) + + async def test_regular_user_cannot_assign_test_resource_users(self) -> None: + lock_batch = AsyncMock() + get_resource = AsyncMock() + with ( + patch.object(TestBatchAccessService, 'lock_active_batch', new=lock_batch), + patch.object(MasterDao, 'get_warehouse', new=get_resource), + self.assertRaisesRegex(PermissionException, '仅超级管理员'), + ): + await PalletService.assign_warehouse_users( + self.db, + 7, + UserAssignModel(userIds=[]), + make_current_user(8), + self.scope, + ) + lock_batch.assert_not_awaited() + get_resource.assert_not_awaited() + + async def test_legacy_pallet_delete_is_rejected_before_any_delete_dao(self) -> None: + lock_inventory = AsyncMock() + delete_inventory = AsyncMock() + delete_pallet = AsyncMock() + with ( + patch.object( + PalletService, + '_lock_pallet_inventory', + new=lock_inventory, + ), + patch.object(MasterDao, 'delete_pallet_inventory', new=delete_inventory, create=True), + patch.object(MasterDao, 'delete_pallet', new=delete_pallet, create=True), + self.assertRaisesRegex(ServiceWarning, '不支持单条物理删除,请通过测试数据批次清理'), + ): + await PalletService.remove_pallet(self.db, DeletePalletModel(palletIds=[4])) + + lock_inventory.assert_not_awaited() + delete_inventory.assert_not_awaited() + delete_pallet.assert_not_awaited() + self.db.commit.assert_not_awaited() + + +class TestPalletDaoBatchScope(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.db = MagicMock(spec=AsyncSession) + + async def test_pallet_list_detail_and_inventory_queries_apply_scope(self) -> None: + query = PalletPageQueryModel() + for scope, expected in ( + (TestBatchScope.formal(), 'test_batch_id IS NULL'), + (make_test_scope(9), 'test_batch_id = 9'), + ): + with ( + self.subTest(scope=scope.mode, operation='list'), + patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=[])) as paginate, + ): + await MasterDao.list_pallet(self.db, query, scope, is_page=False) + sql = compile_sql(paginate.await_args.args[1]) + self.assertIn(f'biz_pallet.{expected}', sql) + + for method_name, table in ( + ('get_pallet', 'biz_pallet'), + ('get_pallet_inventory', 'biz_pallet_inventory'), + ): + result = MagicMock() + result.scalar_one_or_none.return_value = None + self.db.execute = AsyncMock(return_value=result) + with self.subTest(scope=scope.mode, operation=method_name): + await getattr(MasterDao, method_name)(self.db, 7, scope, for_update=True) + sql = compile_sql(self.db.execute.await_args.args[0]) + self.assertIn(f'{table}.{expected}', sql) + self.assertIn('FOR UPDATE', sql) + + async def test_pallet_update_is_scoped_and_never_updates_batch_id(self) -> None: + edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0') + await MasterDao.update_pallet( + self.db, + edit, + make_test_scope(9), + update_by='admin', + update_time=datetime(2026, 9, 22), + ) + sql = compile_sql(self.db.execute.await_args.args[0]) + self.assertIn('biz_pallet.test_batch_id = 9', sql) + self.assertNotIn('SET test_batch_id=', sql) + + class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase): @staticmethod def _routers() -> tuple[Any, ...]: @@ -1239,6 +1344,20 @@ class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase): ) ) + def test_all_non_delete_routes_have_matching_batch_scope_dependency(self) -> None: + for (path, method), route in self._routes().items(): + resolvers = [ + dependency.call + for dependency in route.dependant.dependencies + if isinstance(dependency.call, TestBatchScopeResolver) + ] + with self.subTest(path=path, method=method): + if method == 'DELETE': + self.assertEqual([], resolvers) + else: + self.assertEqual(1, len(resolvers)) + self.assertIs(method in {'POST', 'PUT'}, resolvers[0].write) + def test_qrcode_route_is_declared_before_dynamic_detail(self) -> None: route_paths = [route.path for route in pallet_controller_module.pallet_controller.routes] self.assertLess( @@ -1249,14 +1368,16 @@ class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase): async def test_qrcode_endpoint_returns_real_image_png_body(self) -> None: png = PalletService.render_qr_png('persisted-token') db = MagicMock(spec=AsyncSession) + scope = TestBatchScope.formal() with patch.object(PalletService, 'get_pallet_qrcode', new=AsyncMock(return_value=png)) as get_qrcode: response = await pallet_controller_module.query_master_pallet_qrcode( request=MagicMock(), pallet_id=7, query_db=db, + scope=scope, ) - get_qrcode.assert_awaited_once_with(db, 7) + get_qrcode.assert_awaited_once_with(db, 7, scope) self.assertEqual('image/png', response.media_type) self.assertEqual(b'\x89PNG\r\n\x1a\n', response.body[:8]) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py index a994f02..f9f550d 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py @@ -97,7 +97,6 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', - 'can_remove', }, StrainPageQueryModel: {'page_num', 'page_size', 'strain_code', 'strain_name', 'status'}, DeleteStrainModel: {'strain_ids'}, @@ -111,7 +110,6 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', - 'can_remove', }, CustomerPageQueryModel: {'page_num', 'page_size', 'customer_code', 'customer_name', 'status'}, DeleteCustomerModel: {'customer_ids'}, @@ -127,7 +125,6 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', - 'can_remove', }, SitePageQueryModel: {'page_num', 'page_size', 'customer_id', 'site_code', 'site_name', 'status'}, DeleteSiteModel: {'site_ids'}, @@ -142,7 +139,6 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', - 'can_remove', }, LinePageQueryModel: {'page_num', 'page_size', 'site_id', 'line_code', 'line_name', 'status'}, DeleteLineModel: {'line_ids'}, @@ -157,7 +153,6 @@ MASTER_MODEL_FIELDS = { 'update_by', 'update_time', 'remark', - 'can_remove', }, WarehousePageQueryModel: {'page_num', 'page_size', 'warehouse_code', 'warehouse_name', 'status'}, DeleteWarehouseModel: {'warehouse_ids'}, @@ -172,7 +167,6 @@ MASTER_MODEL_FIELDS = { 'create_time', 'update_by', 'update_time', - 'can_remove', }, PalletCreateModel: {'pallet_code', 'capacity_packages', 'remark'}, PalletEditModel: {'pallet_id', 'capacity_packages', 'asset_status', 'remark'}, @@ -858,6 +852,26 @@ class BusinessVoContractTest(unittest.TestCase): with self.subTest(model=model_class.__name__): self.assertEqual('forbid', model_class.model_config.get('extra')) + def test_master_write_models_reject_untrusted_test_batch_id(self) -> None: + payloads = ( + (StrainModel, {'strainCode': 'S-1', 'strainName': '菌种'}), + (CustomerModel, {'customerCode': 'C-1', 'customerName': '客户'}), + ( + SiteModel, + {'customerId': 1, 'siteCode': 'SITE-1', 'siteName': '现场'}, + ), + (LineModel, {'siteId': 1, 'lineCode': 'L-1', 'lineName': '产线'}), + (WarehouseModel, {'warehouseCode': 'W-1', 'warehouseName': '仓库'}), + (PalletCreateModel, {'palletCode': 'P-1', 'capacityPackages': 20}), + ( + PalletEditModel, + {'palletId': 1, 'capacityPackages': 20, 'assetStatus': '0'}, + ), + ) + for model_class, payload in payloads: + with self.subTest(model=model_class.__name__), self.assertRaises(ValidationError): + model_class.model_validate(payload | {'testBatchId': 9}) + def test_all_test_batch_models_forbid_extra_fields(self) -> None: for model_class in TEST_BATCH_MODEL_FIELDS: with self.subTest(model=model_class.__name__):