diff --git a/shierfeng-fastapi-backend/module_business/controller/report_controller.py b/shierfeng-fastapi-backend/module_business/controller/report_controller.py index e88fb76..7f11959 100644 --- a/shierfeng-fastapi-backend/module_business/controller/report_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/report_controller.py @@ -11,6 +11,8 @@ from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.router import APIRouterPro from common.vo import PageResponseModel from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dependency.test_batch_dependency import TestBatchScopeDependency +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.report_vo import ( ConsumptionReportModel, ConsumptionReportQueryModel, @@ -46,8 +48,9 @@ async def get_pallet_inventory_report_list( query_object: Annotated[PalletInventoryReportQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ReportService.get_pallet_inventory_list_services(query_db, query_object, current_user) + result = await ReportService.get_pallet_inventory_list_services(query_db, query_object, current_user, test_scope) return ResponseUtil.success(model_content=result) @@ -62,8 +65,9 @@ async def get_filling_report_list( query_object: Annotated[FillingReportQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ReportService.get_filling_report_list_services(query_db, query_object, current_user) + result = await ReportService.get_filling_report_list_services(query_db, query_object, current_user, test_scope) return ResponseUtil.success(model_content=result) @@ -78,8 +82,9 @@ async def get_shipment_report_list( query_object: Annotated[ShipmentReportQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ReportService.get_shipment_report_list_services(query_db, query_object, current_user) + result = await ReportService.get_shipment_report_list_services(query_db, query_object, current_user, test_scope) return ResponseUtil.success(model_content=result) @@ -94,8 +99,9 @@ async def get_consumption_report_list( query_object: Annotated[ConsumptionReportQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ReportService.get_consumption_report_list_services(query_db, query_object, current_user) + result = await ReportService.get_consumption_report_list_services(query_db, query_object, current_user, test_scope) return ResponseUtil.success(model_content=result) @@ -110,8 +116,9 @@ async def get_return_report_list( query_object: Annotated[ReturnReportQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ReportService.get_return_report_list_services(query_db, query_object, current_user) + result = await ReportService.get_return_report_list_services(query_db, query_object, current_user, test_scope) return ResponseUtil.success(model_content=result) @@ -126,6 +133,7 @@ async def get_pallet_trace_report_list( query_object: Annotated[PalletTraceReportQueryModel, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: - result = await ReportService.get_pallet_trace_list_services(query_db, query_object, current_user) + result = await ReportService.get_pallet_trace_list_services(query_db, query_object, current_user, test_scope) return ResponseUtil.success(model_content=result) diff --git a/shierfeng-fastapi-backend/module_business/dao/access_dao.py b/shierfeng-fastapi-backend/module_business/dao/access_dao.py index d4a329b..f75460a 100644 --- a/shierfeng-fastapi-backend/module_business/dao/access_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/access_dao.py @@ -1,7 +1,8 @@ from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession -from module_business.entity.do.master_do import BizUserSite, BizUserWarehouse +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition +from module_business.entity.do.master_do import BizCustomerSite, BizUserSite, BizUserWarehouse, BizWarehouse class AccessDao: @@ -48,16 +49,44 @@ class AccessDao: return mapped_user_id is not None @classmethod - async def list_user_warehouse_ids(cls, db: AsyncSession, user_id: int) -> list[int]: - """获取用户关联的仓库 ID 列表。""" - result = await db.execute(select(BizUserWarehouse.warehouse_id).where(BizUserWarehouse.user_id == user_id)) + async def list_user_warehouse_ids( + cls, + db: AsyncSession, + user_id: int, + scope: TestBatchScope | None = None, + ) -> list[int]: + """获取用户关联且处于当前批次作用域的仓库 ID 列表。""" + conditions = [BizUserWarehouse.user_id == user_id] + if scope is not None: + conditions.append(batch_scope_condition(BizWarehouse.test_batch_id, scope)) + statement = ( + select(BizUserWarehouse.warehouse_id) + .select_from(BizUserWarehouse) + .join(BizWarehouse, BizWarehouse.warehouse_id == BizUserWarehouse.warehouse_id) + .where(*conditions) + ) + result = await db.execute(statement) return list(result.scalars().all()) @classmethod - async def list_user_site_ids(cls, db: AsyncSession, user_id: int) -> list[int]: - """获取用户关联的客户现场 ID 列表。""" - result = await db.execute(select(BizUserSite.site_id).where(BizUserSite.user_id == user_id)) + async def list_user_site_ids( + cls, + db: AsyncSession, + user_id: int, + scope: TestBatchScope | None = None, + ) -> list[int]: + """获取用户关联且处于当前批次作用域的客户现场 ID 列表。""" + conditions = [BizUserSite.user_id == user_id] + if scope is not None: + conditions.append(batch_scope_condition(BizCustomerSite.test_batch_id, scope)) + statement = ( + select(BizUserSite.site_id) + .select_from(BizUserSite) + .join(BizCustomerSite, BizCustomerSite.site_id == BizUserSite.site_id) + .where(*conditions) + ) + result = await db.execute(statement) return list(result.scalars().all()) diff --git a/shierfeng-fastapi-backend/module_business/dao/report_dao.py b/shierfeng-fastapi-backend/module_business/dao/report_dao.py index dcfe85c..e29aead 100644 --- a/shierfeng-fastapi-backend/module_business/dao/report_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/report_dao.py @@ -9,6 +9,7 @@ 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 DocumentStatus, PalletEventType +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition from module_business.entity.do.master_do import ( BizCustomer, BizCustomerSite, @@ -74,6 +75,15 @@ class ReportDao: normalized_ids = sorted(set(scope_ids)) return column.in_(normalized_ids) if normalized_ids else false() + @staticmethod + def _joined_scope_condition( + id_column: InstrumentedAttribute, + scope_column: InstrumentedAttribute, + scope: TestBatchScope, + ) -> ColumnElement[bool]: + """保留空的可选 join,同时拒绝已存在的跨批次关联记录。""" + return or_(id_column.is_(None), batch_scope_condition(scope_column, scope)) + @classmethod def _or_scope_conditions( cls, @@ -140,6 +150,7 @@ class ReportDao: cls, db: AsyncSession, query_object: PalletInventoryReportQueryModel, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, site_ids: list[int] | None = None, ) -> PageModel[PalletInventoryReportModel]: @@ -148,9 +159,19 @@ class ReportDao: current_line = aliased(BizProductionLine, name='current_line') active_filling = aliased(BizFillingRecord, name='active_filling') - conditions = cls._or_scope_conditions( - (BizPalletInventory.warehouse_id, warehouse_ids), - (BizPalletInventory.site_id, site_ids), + conditions = [ + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + batch_scope_condition(BizPallet.test_batch_id, scope), + cls._joined_scope_condition(active_filling.filling_id, active_filling.test_batch_id, scope), + cls._joined_scope_condition(current_warehouse.warehouse_id, current_warehouse.test_batch_id, scope), + cls._joined_scope_condition(current_site.site_id, current_site.test_batch_id, scope), + cls._joined_scope_condition(current_line.line_id, current_line.test_batch_id, scope), + ] + conditions.extend( + cls._or_scope_conditions( + (BizPalletInventory.warehouse_id, warehouse_ids), + (BizPalletInventory.site_id, site_ids), + ) ) if query_object.pallet_code: conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) @@ -209,10 +230,15 @@ class ReportDao: cls, db: AsyncSession, query_object: FillingReportQueryModel, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, ) -> PageModel[FillingReportModel]: operator_user = aliased(SysUser, name='operator_user') - conditions = [] + conditions = [ + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + batch_scope_condition(BizPallet.test_batch_id, scope), + cls._joined_scope_condition(BizWarehouse.warehouse_id, BizWarehouse.test_batch_id, scope), + ] warehouse_scope = cls._scope_condition(BizFillingRecord.warehouse_id, warehouse_ids) if warehouse_scope is not None: conditions.append(warehouse_scope) @@ -264,13 +290,22 @@ class ReportDao: cls, db: AsyncSession, query_object: ShipmentReportQueryModel, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, site_ids: list[int] | None = None, ) -> PageModel[ShipmentReportModel]: source_warehouse = aliased(BizWarehouse, name='source_warehouse') target_site = aliased(BizCustomerSite, name='target_site') target_customer = aliased(BizCustomer, name='target_customer') - conditions = [BizShipmentOrder.status == DocumentStatus.CONFIRMED.value] + conditions = [ + batch_scope_condition(BizShipmentOrder.test_batch_id, scope), + batch_scope_condition(BizShipmentItem.test_batch_id, scope), + batch_scope_condition(BizPallet.test_batch_id, scope), + cls._joined_scope_condition(source_warehouse.warehouse_id, source_warehouse.test_batch_id, scope), + cls._joined_scope_condition(target_site.site_id, target_site.test_batch_id, scope), + cls._joined_scope_condition(target_customer.customer_id, target_customer.test_batch_id, scope), + BizShipmentOrder.status == DocumentStatus.CONFIRMED.value, + ] warehouse_scope = cls._scope_condition(BizShipmentOrder.source_warehouse_id, warehouse_ids) site_scope = cls._scope_condition(BizShipmentOrder.target_site_id, site_ids) if warehouse_scope is not None: @@ -334,10 +369,18 @@ class ReportDao: cls, db: AsyncSession, query_object: ConsumptionReportQueryModel, + scope: TestBatchScope, site_ids: list[int] | None = None, ) -> PageModel[ConsumptionReportModel]: operator_user = aliased(SysUser, name='operator_user') - conditions = [] + conditions = [ + batch_scope_condition(BizUsageRecord.test_batch_id, scope), + batch_scope_condition(BizPallet.test_batch_id, scope), + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + batch_scope_condition(BizCustomerSite.test_batch_id, scope), + batch_scope_condition(BizProductionLine.test_batch_id, scope), + cls._joined_scope_condition(BizCustomer.customer_id, BizCustomer.test_batch_id, scope), + ] site_scope = cls._scope_condition(BizUsageRecord.site_id, site_ids) if site_scope is not None: conditions.append(site_scope) @@ -400,14 +443,24 @@ class ReportDao: cls, db: AsyncSession, query_object: ReturnReportQueryModel, + scope: TestBatchScope, site_ids: list[int] | None = None, warehouse_ids: list[int] | None = None, ) -> PageModel[ReturnReportModel]: source_site = aliased(BizCustomerSite, name='source_site') target_warehouse = aliased(BizWarehouse, name='target_warehouse') - conditions = cls._or_scope_conditions( - (BizReturnOrder.source_site_id, site_ids), - (BizReturnOrder.target_warehouse_id, warehouse_ids), + conditions = [ + batch_scope_condition(BizReturnOrder.test_batch_id, scope), + batch_scope_condition(BizReturnItem.test_batch_id, scope), + batch_scope_condition(BizPallet.test_batch_id, scope), + cls._joined_scope_condition(source_site.site_id, source_site.test_batch_id, scope), + cls._joined_scope_condition(target_warehouse.warehouse_id, target_warehouse.test_batch_id, scope), + ] + conditions.extend( + cls._or_scope_conditions( + (BizReturnOrder.source_site_id, site_ids), + (BizReturnOrder.target_warehouse_id, warehouse_ids), + ) ) if query_object.return_no: conditions.append(BizReturnOrder.return_no.like(f'%{query_object.return_no}%')) @@ -458,6 +511,7 @@ class ReportDao: cls, db: AsyncSession, query_object: PalletTraceReportQueryModel, + scope: TestBatchScope, warehouse_ids: list[int] | None = None, site_ids: list[int] | None = None, ) -> PageModel[PalletTraceReportModel]: @@ -468,7 +522,17 @@ class ReportDao: before_line = aliased(BizProductionLine, name='before_line') after_line = aliased(BizProductionLine, name='after_line') operator_user = aliased(SysUser, name='operator_user') - conditions = cls._pallet_trace_scope_conditions(warehouse_ids, site_ids) + conditions = [ + batch_scope_condition(BizPalletEvent.test_batch_id, scope), + batch_scope_condition(BizPallet.test_batch_id, scope), + cls._joined_scope_condition(before_warehouse.warehouse_id, before_warehouse.test_batch_id, scope), + cls._joined_scope_condition(after_warehouse.warehouse_id, after_warehouse.test_batch_id, scope), + cls._joined_scope_condition(before_site.site_id, before_site.test_batch_id, scope), + cls._joined_scope_condition(after_site.site_id, after_site.test_batch_id, scope), + cls._joined_scope_condition(before_line.line_id, before_line.test_batch_id, scope), + cls._joined_scope_condition(after_line.line_id, after_line.test_batch_id, scope), + ] + conditions.extend(cls._pallet_trace_scope_conditions(warehouse_ids, site_ids)) if query_object.pallet_code: conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) if query_object.event_type: diff --git a/shierfeng-fastapi-backend/module_business/service/report_service.py b/shierfeng-fastapi-backend/module_business/service/report_service.py index d995dd9..c195a29 100644 --- a/shierfeng-fastapi-backend/module_business/service/report_service.py +++ b/shierfeng-fastapi-backend/module_business/service/report_service.py @@ -8,6 +8,7 @@ from exceptions.exception import ServiceException from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.access_dao import AccessDao from module_business.dao.report_dao import ReportDao +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.report_vo import ( ConsumptionReportModel, ConsumptionReportQueryModel, @@ -44,10 +45,11 @@ class ReportService: cls, query_db: AsyncSession, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> list[int] | None: if cls._is_admin(current_user): return None - warehouse_ids = await AccessDao.list_user_warehouse_ids(query_db, cls._user_id(current_user)) + warehouse_ids = await AccessDao.list_user_warehouse_ids(query_db, cls._user_id(current_user), test_scope) return sorted(set(warehouse_ids)) @classmethod @@ -55,10 +57,11 @@ class ReportService: cls, query_db: AsyncSession, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> list[int] | None: if cls._is_admin(current_user): return None - site_ids = await AccessDao.list_user_site_ids(query_db, cls._user_id(current_user)) + site_ids = await AccessDao.list_user_site_ids(query_db, cls._user_id(current_user), test_scope) return sorted(set(site_ids)) @classmethod @@ -66,12 +69,13 @@ class ReportService: cls, query_db: AsyncSession, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> tuple[list[int] | None, list[int] | None]: if cls._is_admin(current_user): return None, None return ( - await cls._warehouse_scope(query_db, current_user), - await cls._site_scope(query_db, current_user), + await cls._warehouse_scope(query_db, current_user, test_scope), + await cls._site_scope(query_db, current_user, test_scope), ) @staticmethod @@ -95,13 +99,15 @@ class ReportService: query_db: AsyncSession, query_object: PalletInventoryReportQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PageModel[PalletInventoryReportModel]: - warehouse_ids, site_ids = await cls._scopes(query_db, current_user) + warehouse_ids, site_ids = await cls._scopes(query_db, current_user, test_scope) if warehouse_ids == [] and site_ids == []: return cls._empty_page(query_object) page = await ReportDao.get_pallet_inventory_list( query_db, query_object, + test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids, ) @@ -113,13 +119,15 @@ class ReportService: query_db: AsyncSession, query_object: FillingReportQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PageModel[FillingReportModel]: - warehouse_ids = await cls._warehouse_scope(query_db, current_user) + warehouse_ids = await cls._warehouse_scope(query_db, current_user, test_scope) if warehouse_ids == []: return cls._empty_page(query_object) page = await ReportDao.get_filling_report_list( query_db, query_object, + test_scope, warehouse_ids=warehouse_ids, ) return cls._convert_page(page, FillingReportModel) @@ -130,13 +138,15 @@ class ReportService: query_db: AsyncSession, query_object: ShipmentReportQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PageModel[ShipmentReportModel]: - warehouse_ids, site_ids = await cls._scopes(query_db, current_user) + warehouse_ids, site_ids = await cls._scopes(query_db, current_user, test_scope) if warehouse_ids == [] or site_ids == []: return cls._empty_page(query_object) page = await ReportDao.get_shipment_report_list( query_db, query_object, + test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids, ) @@ -148,13 +158,15 @@ class ReportService: query_db: AsyncSession, query_object: ConsumptionReportQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PageModel[ConsumptionReportModel]: - site_ids = await cls._site_scope(query_db, current_user) + site_ids = await cls._site_scope(query_db, current_user, test_scope) if site_ids == []: return cls._empty_page(query_object) page = await ReportDao.get_consumption_report_list( query_db, query_object, + test_scope, site_ids=site_ids, ) return cls._convert_page(page, ConsumptionReportModel) @@ -165,13 +177,15 @@ class ReportService: query_db: AsyncSession, query_object: ReturnReportQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PageModel[ReturnReportModel]: - warehouse_ids, site_ids = await cls._scopes(query_db, current_user) + warehouse_ids, site_ids = await cls._scopes(query_db, current_user, test_scope) if warehouse_ids == [] and site_ids == []: return cls._empty_page(query_object) page = await ReportDao.get_return_report_list( query_db, query_object, + test_scope, site_ids=site_ids, warehouse_ids=warehouse_ids, ) @@ -183,13 +197,15 @@ class ReportService: query_db: AsyncSession, query_object: PalletTraceReportQueryModel, current_user: CurrentUserModel, + test_scope: TestBatchScope, ) -> PageModel[PalletTraceReportModel]: - warehouse_ids, site_ids = await cls._scopes(query_db, current_user) + warehouse_ids, site_ids = await cls._scopes(query_db, current_user, test_scope) if warehouse_ids == [] and site_ids == []: return cls._empty_page(query_object) page = await ReportDao.get_pallet_trace_list( query_db, query_object, + test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids, ) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_access_service.py b/shierfeng-fastapi-backend/tests/module_business/test_access_service.py index a7faf72..9b13e76 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_access_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_access_service.py @@ -11,6 +11,7 @@ from sqlalchemy.sql.elements import BinaryExpression, BindParameter, BooleanClau from exceptions.exception import PermissionException from module_business.dao.access_dao import AccessDao +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizUserSite, BizUserWarehouse from module_business.service.access_service import AccessService @@ -260,32 +261,28 @@ class AccessDaoTest(unittest.IsolatedAsyncioTestCase): result.scalars.return_value.all.return_value = (11, 13) db = self._db_with_result(result) - warehouse_ids = await AccessDao.list_user_warehouse_ids(db, user_id=8) + warehouse_ids = await AccessDao.list_user_warehouse_ids(db, user_id=8, scope=TestBatchScope.formal()) self.assertEqual([11, 13], warehouse_ids) statement = db.execute.await_args.args[0] - self._assert_statement_targets( - statement, - BizUserWarehouse, - BizUserWarehouse.warehouse_id, - ((BizUserWarehouse.user_id, 8),), - ) + sql = ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split()) + self.assertIn('JOIN biz_warehouse', sql) + self.assertIn('biz_user_warehouse.user_id = 8', sql) + self.assertIn('biz_warehouse.test_batch_id IS NULL', sql) async def test_list_user_site_ids_returns_scalar_ids(self) -> None: result = MagicMock() result.scalars.return_value.all.return_value = (3, 5) db = self._db_with_result(result) - site_ids = await AccessDao.list_user_site_ids(db, user_id=8) + site_ids = await AccessDao.list_user_site_ids(db, user_id=8, scope=TestBatchScope.formal()) self.assertEqual([3, 5], site_ids) statement = db.execute.await_args.args[0] - self._assert_statement_targets( - statement, - BizUserSite, - BizUserSite.site_id, - ((BizUserSite.user_id, 8),), - ) + sql = ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split()) + self.assertIn('JOIN biz_customer_site', sql) + self.assertIn('biz_user_site.user_id = 8', sql) + self.assertIn('biz_customer_site.test_batch_id IS NULL', sql) async def test_replace_warehouse_users_replaces_complete_mapping(self) -> None: db = AsyncMock(spec=AsyncSession) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_report_service.py b/shierfeng-fastapi-backend/tests/module_business/test_report_service.py index 11d4269..1e7d30c 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_report_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_report_service.py @@ -22,6 +22,7 @@ from module_business.controller import report_controller as report_controller_mo from module_business.dao import report_dao as report_dao_module from module_business.dao.access_dao import AccessDao from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus +from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.report_vo import ( ConsumptionReportModel, ConsumptionReportQueryModel, @@ -206,12 +207,12 @@ class TestReportPublicContract(unittest.TestCase): report_dao = getattr(report_dao_module, 'ReportDao', None) self.assertIsNotNone(report_dao, 'ReportDao 尚未实现') expected_signatures = { - 'get_pallet_inventory_list': ('db', 'query_object', 'warehouse_ids', 'site_ids'), - 'get_filling_report_list': ('db', 'query_object', 'warehouse_ids'), - 'get_shipment_report_list': ('db', 'query_object', 'warehouse_ids', 'site_ids'), - 'get_consumption_report_list': ('db', 'query_object', 'site_ids'), - 'get_return_report_list': ('db', 'query_object', 'site_ids', 'warehouse_ids'), - 'get_pallet_trace_list': ('db', 'query_object', 'warehouse_ids', 'site_ids'), + 'get_pallet_inventory_list': ('db', 'query_object', 'scope', 'warehouse_ids', 'site_ids'), + 'get_filling_report_list': ('db', 'query_object', 'scope', 'warehouse_ids'), + 'get_shipment_report_list': ('db', 'query_object', 'scope', 'warehouse_ids', 'site_ids'), + 'get_consumption_report_list': ('db', 'query_object', 'scope', 'site_ids'), + 'get_return_report_list': ('db', 'query_object', 'scope', 'site_ids', 'warehouse_ids'), + 'get_pallet_trace_list': ('db', 'query_object', 'scope', 'warehouse_ids', 'site_ids'), } for method_name, expected_parameters in expected_signatures.items(): @@ -237,7 +238,7 @@ class TestReportPublicContract(unittest.TestCase): method = getattr(report_service, method_name, None) self.assertIsNotNone(method, f'ReportService.{method_name} 尚未实现') self.assertEqual( - ('query_db', 'query_object', 'current_user'), + ('query_db', 'query_object', 'current_user', 'test_scope'), tuple(inspect.signature(method).parameters), ) @@ -335,6 +336,7 @@ class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase): hasNext=False, ) paginate = AsyncMock(return_value=page) + scope.setdefault('scope', TestBatchScope.formal()) with patch.object(PageUtil, 'paginate', new=paginate): try: result = await method(self.db, query_object, **scope) @@ -659,23 +661,24 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase): dao_method = AsyncMock(return_value=page) warehouse_scope = AsyncMock(return_value=[8, 7, 7]) site_scope = AsyncMock(return_value=[10, 9, 9]) + test_scope = TestBatchScope.formal() with ( patch.object(ReportDao, dao_name, new=dao_method), patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope), patch.object(AccessDao, 'list_user_site_ids', new=site_scope), ): try: - result = await getattr(ReportService, service_name)(self.db, query_object, self.user) + result = await getattr(ReportService, service_name)(self.db, query_object, self.user, test_scope) except NotImplementedError: self.fail(f'ReportService.{service_name} 尚未实现数据范围和行转换') - dao_method.assert_awaited_once_with(self.db, query_object, **expected_scope) + dao_method.assert_awaited_once_with(self.db, query_object, test_scope, **expected_scope) if 'warehouse_ids' in expected_scope: - warehouse_scope.assert_awaited_once_with(self.db, 7) + warehouse_scope.assert_awaited_once_with(self.db, 7, test_scope) else: warehouse_scope.assert_not_awaited() if 'site_ids' in expected_scope: - site_scope.assert_awaited_once_with(self.db, 7) + site_scope.assert_awaited_once_with(self.db, 7, test_scope) else: site_scope.assert_not_awaited() self.assertEqual(17, result.total) @@ -779,6 +782,7 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase): ) for service_name, dao_name, query_object, expected_scope in cases: with self.subTest(service_name=service_name): + test_scope = TestBatchScope.formal() dao_method = AsyncMock( return_value=PageModel( rows=[], @@ -796,13 +800,13 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_site_ids', new=site_scope), ): try: - await getattr(ReportService, service_name)(self.db, query_object, make_user(1)) + await getattr(ReportService, service_name)(self.db, query_object, make_user(1), test_scope) except NotImplementedError: self.fail(f'ReportService.{service_name} 尚未实现管理员范围语义') warehouse_scope.assert_not_awaited() site_scope.assert_not_awaited() - dao_method.assert_awaited_once_with(self.db, query_object, **expected_scope) + dao_method.assert_awaited_once_with(self.db, query_object, test_scope, **expected_scope) async def _assert_empty_page_without_dao( self, @@ -813,6 +817,7 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase): site_ids: list[int] | None, ) -> None: dao_method = AsyncMock() + test_scope = TestBatchScope.formal() with ( patch.object(ReportDao, dao_name, new=dao_method), patch.object( @@ -827,7 +832,7 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase): ), ): try: - result = await getattr(ReportService, service_name)(self.db, query_object, self.user) + result = await getattr(ReportService, service_name)(self.db, query_object, self.user, test_scope) except NotImplementedError: self.fail(f'ReportService.{service_name} 尚未实现空范围短路') @@ -888,7 +893,12 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[])), ): try: - await getattr(ReportService, service_name)(self.db, query_object, self.user) + await getattr(ReportService, service_name)( + self.db, + query_object, + self.user, + TestBatchScope.formal(), + ) except NotImplementedError: self.fail(f'ReportService.{service_name} 尚未实现单侧 OR 范围可见性') dao_method.assert_awaited_once() diff --git a/shierfeng-fastapi-backend/tests/module_business/test_test_batch_query_scope_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_test_batch_query_scope_contract.py new file mode 100644 index 0000000..584c54d --- /dev/null +++ b/shierfeng-fastapi-backend/tests/module_business/test_test_batch_query_scope_contract.py @@ -0,0 +1,235 @@ +import ast +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from sqlalchemy.dialects import mysql, postgresql +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_business.dao import report_dao as report_dao_module +from module_business.dao.access_dao import AccessDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.report_vo import ( + ConsumptionReportQueryModel, + FillingReportQueryModel, + PalletInventoryReportQueryModel, + PalletTraceReportQueryModel, + ReturnReportQueryModel, + ShipmentReportQueryModel, +) +from module_business.service.report_service import ReportService +from utils.page_util import PageUtil + +BACKEND_ROOT = Path(__file__).resolve().parents[2] + + +SCOPED_DAO_METHODS = { + 'module_business/dao/master_dao.py': { + 'list_strain', + 'get_strain', + 'list_customer', + 'get_customer', + 'list_site', + 'get_site', + 'list_line', + 'get_line', + 'list_warehouse', + 'get_warehouse', + 'list_pallet', + 'get_pallet', + }, + 'module_business/dao/operation_dao.py': { + 'get_inbound_list', + 'get_inbound_by_id', + 'get_filling_list', + 'get_filling_by_id', + 'get_shipment_list', + 'get_shipment_by_id', + 'get_usage_list', + 'get_usage_by_id', + 'get_return_list', + 'get_return_by_id', + }, + 'module_business/dao/report_dao.py': { + 'get_pallet_inventory_list', + 'get_filling_report_list', + 'get_shipment_report_list', + 'get_consumption_report_list', + 'get_return_report_list', + 'get_pallet_trace_list', + }, +} + + +def make_test_scope(batch_id: int) -> TestBatchScope: + return TestBatchScope( + batch_id=batch_id, + mode='TEST', + status=SimpleNamespace(value='ACTIVE'), + writable=True, + ) + + +def _compile(statement: object) -> str: + return ' '.join( + ' '.join(str(statement.compile(dialect=dialect, compile_kwargs={'literal_binds': True})).lower().split()) + for dialect in (mysql.dialect(), postgresql.dialect()) + ) + + +class TestScopedDaoSignatureContract(unittest.TestCase): + def test_all_public_read_daos_require_test_batch_scope(self) -> None: + for relative_path, method_names in SCOPED_DAO_METHODS.items(): + tree = ast.parse((BACKEND_ROOT / relative_path).read_text(encoding='utf-8')) + methods = { + node.name: node for node in ast.walk(tree) if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)) + } + for method_name in method_names: + with self.subTest(path=relative_path, method=method_name): + method = methods[method_name] + parameters = [*method.args.posonlyargs, *method.args.args, *method.args.kwonlyargs] + scope = next((parameter for parameter in parameters if parameter.arg == 'scope'), None) + self.assertIsNotNone(scope) + self.assertIn('TestBatchScope', ast.unparse(scope.annotation)) + + +class TestAccessDaoBatchScope(unittest.IsolatedAsyncioTestCase): + async def _capture_ids(self, method_name: str, scope: TestBatchScope) -> str: + result = MagicMock() + result.scalars.return_value.all.return_value = [7] + db = AsyncMock(spec=AsyncSession) + db.execute.return_value = result + + await getattr(AccessDao, method_name)(db, 8, scope) + statement = db.execute.await_args.args[0] + return _compile(statement) + + async def test_authorized_warehouse_ids_join_and_filter_resource_scope(self) -> None: + sql = await self._capture_ids('list_user_warehouse_ids', make_test_scope(7)) + self.assertIn('join biz_warehouse', sql) + self.assertIn('biz_warehouse.test_batch_id = 7', sql) + + async def test_authorized_site_ids_formal_scope_filters_formal_resource(self) -> None: + sql = await self._capture_ids('list_user_site_ids', TestBatchScope.formal()) + self.assertIn('join biz_customer_site', sql) + self.assertIn('biz_customer_site.test_batch_id is null', sql) + + +class TestReportSqlBatchScope(unittest.IsolatedAsyncioTestCase): + async def _capture_report_sql(self, method_name: str, query_object: object) -> str: + db = MagicMock(spec=AsyncSession) + page = PageModel( + rows=[], pageNum=query_object.page_num, pageSize=query_object.page_size, total=0, hasNext=False + ) + paginate = AsyncMock(return_value=page) + scope_arguments = { + 'get_pallet_inventory_list': {'warehouse_ids': [8, 7], 'site_ids': [10, 9]}, + 'get_filling_report_list': {'warehouse_ids': [8, 7]}, + 'get_shipment_report_list': {'warehouse_ids': [8, 7], 'site_ids': [10, 9]}, + 'get_consumption_report_list': {'site_ids': [10, 9]}, + 'get_return_report_list': {'site_ids': [10, 9], 'warehouse_ids': [8, 7]}, + 'get_pallet_trace_list': {'warehouse_ids': [8, 7], 'site_ids': [10, 9]}, + } + with patch.object(PageUtil, 'paginate', new=paginate): + result = await getattr(report_dao_module.ReportDao, method_name)( + db, + query_object, + make_test_scope(7), + **scope_arguments[method_name], + ) + self.assertIs(page, result) + return _compile(paginate.await_args.args[1]) + + async def test_each_report_root_query_contains_test_batch_condition(self) -> None: + cases = ( + ( + 'get_pallet_inventory_list', + PalletInventoryReportQueryModel(), + ( + 'biz_pallet_inventory.test_batch_id = 7', + 'biz_pallet.test_batch_id = 7', + 'active_filling.test_batch_id = 7', + ), + ), + ( + 'get_filling_report_list', + FillingReportQueryModel(), + ( + 'biz_filling_record.test_batch_id = 7', + 'biz_pallet.test_batch_id = 7', + 'biz_warehouse.test_batch_id = 7', + ), + ), + ( + 'get_shipment_report_list', + ShipmentReportQueryModel(), + ( + 'biz_shipment_order.test_batch_id = 7', + 'biz_shipment_item.test_batch_id = 7', + 'biz_pallet.test_batch_id = 7', + 'source_warehouse.test_batch_id = 7', + 'target_site.test_batch_id = 7', + 'target_customer.test_batch_id = 7', + ), + ), + ( + 'get_consumption_report_list', + ConsumptionReportQueryModel(), + ( + 'biz_usage_record.test_batch_id = 7', + 'biz_pallet.test_batch_id = 7', + 'biz_filling_record.test_batch_id = 7', + 'biz_customer_site.test_batch_id = 7', + 'biz_production_line.test_batch_id = 7', + 'biz_customer.test_batch_id = 7', + ), + ), + ( + 'get_return_report_list', + ReturnReportQueryModel(), + ( + 'biz_return_order.test_batch_id = 7', + 'biz_return_item.test_batch_id = 7', + 'biz_pallet.test_batch_id = 7', + 'source_site.test_batch_id = 7', + 'target_warehouse.test_batch_id = 7', + ), + ), + ( + 'get_pallet_trace_list', + PalletTraceReportQueryModel(), + ( + 'biz_pallet_event.test_batch_id = 7', + 'biz_pallet.test_batch_id = 7', + 'before_warehouse.test_batch_id = 7', + 'after_warehouse.test_batch_id = 7', + 'before_site.test_batch_id = 7', + 'after_site.test_batch_id = 7', + 'before_line.test_batch_id = 7', + 'after_line.test_batch_id = 7', + ), + ), + ) + for method_name, query_object, expected_fragments in cases: + with self.subTest(method=method_name): + sql = await self._capture_report_sql(method_name, query_object) + for fragment in expected_fragments: + self.assertIn(fragment, sql) + + +class TestReportServiceScopeForwarding(unittest.IsolatedAsyncioTestCase): + async def test_formal_scope_is_forwarded_to_report_dao(self) -> None: + db = MagicMock(spec=AsyncSession) + user = SimpleNamespace(user=SimpleNamespace(user_id=1, admin=True)) + query = PalletInventoryReportQueryModel() + page = PageModel(rows=[], pageNum=1, pageSize=10, total=0, hasNext=False) + dao = AsyncMock(return_value=page) + with patch.object(report_dao_module.ReportDao, 'get_pallet_inventory_list', new=dao): + await ReportService.get_pallet_inventory_list_services(db, query, user, TestBatchScope.formal()) + self.assertEqual(TestBatchScope.formal(), dao.await_args.args[2]) + + +if __name__ == '__main__': + unittest.main() diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index fefa2e0..c053738 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -1272,6 +1272,14 @@ test('六个只读报表页面逐页满足 API、权限、筛选、列和异步 } }) +test('首页指标全部来自受批次作用域保护的业务与报表 API', async () => { + const source = await readFile(path.join(frontendRoot, 'src/views/dashboard/index.vue'), 'utf8') + for (const api of ['listInbound', 'listReturn', 'listShipment', 'listPalletInventory', 'listShipmentReport']) { + assert.match(source, new RegExp(`\\b${api}\\b`)) + } + assert.doesNotMatch(source, /axios\.|fetch\(/) +}) + test('扫码组件声明交互契约并保持触控尺寸', async () => { const source = await readFile(path.join(frontendRoot, 'src/components/PalletScanInput/index.vue'), 'utf8')