安全:隔离报表选项扫码与首页数据

This commit is contained in:
焦龙言
2026-09-23 12:37:30 +08:00
parent 5a76fe7521
commit c97f6ed3bd
8 changed files with 429 additions and 62 deletions
@@ -11,6 +11,8 @@ from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.router import APIRouterPro from common.router import APIRouterPro
from common.vo import PageResponseModel from common.vo import PageResponseModel
from module_admin.entity.vo.user_vo import CurrentUserModel 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 ( from module_business.entity.vo.report_vo import (
ConsumptionReportModel, ConsumptionReportModel,
ConsumptionReportQueryModel, ConsumptionReportQueryModel,
@@ -46,8 +48,9 @@ async def get_pallet_inventory_report_list(
query_object: Annotated[PalletInventoryReportQueryModel, Query()], query_object: Annotated[PalletInventoryReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> 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) return ResponseUtil.success(model_content=result)
@@ -62,8 +65,9 @@ async def get_filling_report_list(
query_object: Annotated[FillingReportQueryModel, Query()], query_object: Annotated[FillingReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> 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) return ResponseUtil.success(model_content=result)
@@ -78,8 +82,9 @@ async def get_shipment_report_list(
query_object: Annotated[ShipmentReportQueryModel, Query()], query_object: Annotated[ShipmentReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> 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) return ResponseUtil.success(model_content=result)
@@ -94,8 +99,9 @@ async def get_consumption_report_list(
query_object: Annotated[ConsumptionReportQueryModel, Query()], query_object: Annotated[ConsumptionReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> 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) return ResponseUtil.success(model_content=result)
@@ -110,8 +116,9 @@ async def get_return_report_list(
query_object: Annotated[ReturnReportQueryModel, Query()], query_object: Annotated[ReturnReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> 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) return ResponseUtil.success(model_content=result)
@@ -126,6 +133,7 @@ async def get_pallet_trace_report_list(
query_object: Annotated[PalletTraceReportQueryModel, Query()], query_object: Annotated[PalletTraceReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> 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) return ResponseUtil.success(model_content=result)
@@ -1,7 +1,8 @@
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession 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: class AccessDao:
@@ -48,16 +49,44 @@ class AccessDao:
return mapped_user_id is not None return mapped_user_id is not None
@classmethod @classmethod
async def list_user_warehouse_ids(cls, db: AsyncSession, user_id: int) -> list[int]: async def list_user_warehouse_ids(
"""获取用户关联的仓库 ID 列表。""" cls,
result = await db.execute(select(BizUserWarehouse.warehouse_id).where(BizUserWarehouse.user_id == user_id)) 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()) return list(result.scalars().all())
@classmethod @classmethod
async def list_user_site_ids(cls, db: AsyncSession, user_id: int) -> list[int]: async def list_user_site_ids(
"""获取用户关联的客户现场 ID 列表。""" cls,
result = await db.execute(select(BizUserSite.site_id).where(BizUserSite.user_id == user_id)) 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()) return list(result.scalars().all())
@@ -9,6 +9,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel from common.vo import PageModel
from module_admin.entity.do.user_do import SysUser from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import DocumentStatus, PalletEventType 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 ( from module_business.entity.do.master_do import (
BizCustomer, BizCustomer,
BizCustomerSite, BizCustomerSite,
@@ -74,6 +75,15 @@ class ReportDao:
normalized_ids = sorted(set(scope_ids)) normalized_ids = sorted(set(scope_ids))
return column.in_(normalized_ids) if normalized_ids else false() 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 @classmethod
def _or_scope_conditions( def _or_scope_conditions(
cls, cls,
@@ -140,6 +150,7 @@ class ReportDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
query_object: PalletInventoryReportQueryModel, query_object: PalletInventoryReportQueryModel,
scope: TestBatchScope,
warehouse_ids: list[int] | None = None, warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None, site_ids: list[int] | None = None,
) -> PageModel[PalletInventoryReportModel]: ) -> PageModel[PalletInventoryReportModel]:
@@ -148,9 +159,19 @@ class ReportDao:
current_line = aliased(BizProductionLine, name='current_line') current_line = aliased(BizProductionLine, name='current_line')
active_filling = aliased(BizFillingRecord, name='active_filling') active_filling = aliased(BizFillingRecord, name='active_filling')
conditions = cls._or_scope_conditions( conditions = [
(BizPalletInventory.warehouse_id, warehouse_ids), batch_scope_condition(BizPalletInventory.test_batch_id, scope),
(BizPalletInventory.site_id, site_ids), 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: if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
@@ -209,10 +230,15 @@ class ReportDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
query_object: FillingReportQueryModel, query_object: FillingReportQueryModel,
scope: TestBatchScope,
warehouse_ids: list[int] | None = None, warehouse_ids: list[int] | None = None,
) -> PageModel[FillingReportModel]: ) -> PageModel[FillingReportModel]:
operator_user = aliased(SysUser, name='operator_user') 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) warehouse_scope = cls._scope_condition(BizFillingRecord.warehouse_id, warehouse_ids)
if warehouse_scope is not None: if warehouse_scope is not None:
conditions.append(warehouse_scope) conditions.append(warehouse_scope)
@@ -264,13 +290,22 @@ class ReportDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
query_object: ShipmentReportQueryModel, query_object: ShipmentReportQueryModel,
scope: TestBatchScope,
warehouse_ids: list[int] | None = None, warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None, site_ids: list[int] | None = None,
) -> PageModel[ShipmentReportModel]: ) -> PageModel[ShipmentReportModel]:
source_warehouse = aliased(BizWarehouse, name='source_warehouse') source_warehouse = aliased(BizWarehouse, name='source_warehouse')
target_site = aliased(BizCustomerSite, name='target_site') target_site = aliased(BizCustomerSite, name='target_site')
target_customer = aliased(BizCustomer, name='target_customer') 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) warehouse_scope = cls._scope_condition(BizShipmentOrder.source_warehouse_id, warehouse_ids)
site_scope = cls._scope_condition(BizShipmentOrder.target_site_id, site_ids) site_scope = cls._scope_condition(BizShipmentOrder.target_site_id, site_ids)
if warehouse_scope is not None: if warehouse_scope is not None:
@@ -334,10 +369,18 @@ class ReportDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
query_object: ConsumptionReportQueryModel, query_object: ConsumptionReportQueryModel,
scope: TestBatchScope,
site_ids: list[int] | None = None, site_ids: list[int] | None = None,
) -> PageModel[ConsumptionReportModel]: ) -> PageModel[ConsumptionReportModel]:
operator_user = aliased(SysUser, name='operator_user') 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) site_scope = cls._scope_condition(BizUsageRecord.site_id, site_ids)
if site_scope is not None: if site_scope is not None:
conditions.append(site_scope) conditions.append(site_scope)
@@ -400,14 +443,24 @@ class ReportDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
query_object: ReturnReportQueryModel, query_object: ReturnReportQueryModel,
scope: TestBatchScope,
site_ids: list[int] | None = None, site_ids: list[int] | None = None,
warehouse_ids: list[int] | None = None, warehouse_ids: list[int] | None = None,
) -> PageModel[ReturnReportModel]: ) -> PageModel[ReturnReportModel]:
source_site = aliased(BizCustomerSite, name='source_site') source_site = aliased(BizCustomerSite, name='source_site')
target_warehouse = aliased(BizWarehouse, name='target_warehouse') target_warehouse = aliased(BizWarehouse, name='target_warehouse')
conditions = cls._or_scope_conditions( conditions = [
(BizReturnOrder.source_site_id, site_ids), batch_scope_condition(BizReturnOrder.test_batch_id, scope),
(BizReturnOrder.target_warehouse_id, warehouse_ids), 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: if query_object.return_no:
conditions.append(BizReturnOrder.return_no.like(f'%{query_object.return_no}%')) conditions.append(BizReturnOrder.return_no.like(f'%{query_object.return_no}%'))
@@ -458,6 +511,7 @@ class ReportDao:
cls, cls,
db: AsyncSession, db: AsyncSession,
query_object: PalletTraceReportQueryModel, query_object: PalletTraceReportQueryModel,
scope: TestBatchScope,
warehouse_ids: list[int] | None = None, warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None, site_ids: list[int] | None = None,
) -> PageModel[PalletTraceReportModel]: ) -> PageModel[PalletTraceReportModel]:
@@ -468,7 +522,17 @@ class ReportDao:
before_line = aliased(BizProductionLine, name='before_line') before_line = aliased(BizProductionLine, name='before_line')
after_line = aliased(BizProductionLine, name='after_line') after_line = aliased(BizProductionLine, name='after_line')
operator_user = aliased(SysUser, name='operator_user') 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: if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.event_type: if query_object.event_type:
@@ -8,6 +8,7 @@ from exceptions.exception import ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao from module_business.dao.access_dao import AccessDao
from module_business.dao.report_dao import ReportDao 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 ( from module_business.entity.vo.report_vo import (
ConsumptionReportModel, ConsumptionReportModel,
ConsumptionReportQueryModel, ConsumptionReportQueryModel,
@@ -44,10 +45,11 @@ class ReportService:
cls, cls,
query_db: AsyncSession, query_db: AsyncSession,
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[int] | None: ) -> list[int] | None:
if cls._is_admin(current_user): if cls._is_admin(current_user):
return None 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)) return sorted(set(warehouse_ids))
@classmethod @classmethod
@@ -55,10 +57,11 @@ class ReportService:
cls, cls,
query_db: AsyncSession, query_db: AsyncSession,
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[int] | None: ) -> list[int] | None:
if cls._is_admin(current_user): if cls._is_admin(current_user):
return None 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)) return sorted(set(site_ids))
@classmethod @classmethod
@@ -66,12 +69,13 @@ class ReportService:
cls, cls,
query_db: AsyncSession, query_db: AsyncSession,
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> tuple[list[int] | None, list[int] | None]: ) -> tuple[list[int] | None, list[int] | None]:
if cls._is_admin(current_user): if cls._is_admin(current_user):
return None, None return None, None
return ( return (
await cls._warehouse_scope(query_db, current_user), await cls._warehouse_scope(query_db, current_user, test_scope),
await cls._site_scope(query_db, current_user), await cls._site_scope(query_db, current_user, test_scope),
) )
@staticmethod @staticmethod
@@ -95,13 +99,15 @@ class ReportService:
query_db: AsyncSession, query_db: AsyncSession,
query_object: PalletInventoryReportQueryModel, query_object: PalletInventoryReportQueryModel,
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PageModel[PalletInventoryReportModel]: ) -> 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 == []: if warehouse_ids == [] and site_ids == []:
return cls._empty_page(query_object) return cls._empty_page(query_object)
page = await ReportDao.get_pallet_inventory_list( page = await ReportDao.get_pallet_inventory_list(
query_db, query_db,
query_object, query_object,
test_scope,
warehouse_ids=warehouse_ids, warehouse_ids=warehouse_ids,
site_ids=site_ids, site_ids=site_ids,
) )
@@ -113,13 +119,15 @@ class ReportService:
query_db: AsyncSession, query_db: AsyncSession,
query_object: FillingReportQueryModel, query_object: FillingReportQueryModel,
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PageModel[FillingReportModel]: ) -> 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 == []: if warehouse_ids == []:
return cls._empty_page(query_object) return cls._empty_page(query_object)
page = await ReportDao.get_filling_report_list( page = await ReportDao.get_filling_report_list(
query_db, query_db,
query_object, query_object,
test_scope,
warehouse_ids=warehouse_ids, warehouse_ids=warehouse_ids,
) )
return cls._convert_page(page, FillingReportModel) return cls._convert_page(page, FillingReportModel)
@@ -130,13 +138,15 @@ class ReportService:
query_db: AsyncSession, query_db: AsyncSession,
query_object: ShipmentReportQueryModel, query_object: ShipmentReportQueryModel,
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PageModel[ShipmentReportModel]: ) -> 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 == []: if warehouse_ids == [] or site_ids == []:
return cls._empty_page(query_object) return cls._empty_page(query_object)
page = await ReportDao.get_shipment_report_list( page = await ReportDao.get_shipment_report_list(
query_db, query_db,
query_object, query_object,
test_scope,
warehouse_ids=warehouse_ids, warehouse_ids=warehouse_ids,
site_ids=site_ids, site_ids=site_ids,
) )
@@ -148,13 +158,15 @@ class ReportService:
query_db: AsyncSession, query_db: AsyncSession,
query_object: ConsumptionReportQueryModel, query_object: ConsumptionReportQueryModel,
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PageModel[ConsumptionReportModel]: ) -> 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 == []: if site_ids == []:
return cls._empty_page(query_object) return cls._empty_page(query_object)
page = await ReportDao.get_consumption_report_list( page = await ReportDao.get_consumption_report_list(
query_db, query_db,
query_object, query_object,
test_scope,
site_ids=site_ids, site_ids=site_ids,
) )
return cls._convert_page(page, ConsumptionReportModel) return cls._convert_page(page, ConsumptionReportModel)
@@ -165,13 +177,15 @@ class ReportService:
query_db: AsyncSession, query_db: AsyncSession,
query_object: ReturnReportQueryModel, query_object: ReturnReportQueryModel,
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PageModel[ReturnReportModel]: ) -> 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 == []: if warehouse_ids == [] and site_ids == []:
return cls._empty_page(query_object) return cls._empty_page(query_object)
page = await ReportDao.get_return_report_list( page = await ReportDao.get_return_report_list(
query_db, query_db,
query_object, query_object,
test_scope,
site_ids=site_ids, site_ids=site_ids,
warehouse_ids=warehouse_ids, warehouse_ids=warehouse_ids,
) )
@@ -183,13 +197,15 @@ class ReportService:
query_db: AsyncSession, query_db: AsyncSession,
query_object: PalletTraceReportQueryModel, query_object: PalletTraceReportQueryModel,
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PageModel[PalletTraceReportModel]: ) -> 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 == []: if warehouse_ids == [] and site_ids == []:
return cls._empty_page(query_object) return cls._empty_page(query_object)
page = await ReportDao.get_pallet_trace_list( page = await ReportDao.get_pallet_trace_list(
query_db, query_db,
query_object, query_object,
test_scope,
warehouse_ids=warehouse_ids, warehouse_ids=warehouse_ids,
site_ids=site_ids, site_ids=site_ids,
) )
@@ -11,6 +11,7 @@ from sqlalchemy.sql.elements import BinaryExpression, BindParameter, BooleanClau
from exceptions.exception import PermissionException from exceptions.exception import PermissionException
from module_business.dao.access_dao import AccessDao 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.entity.do.master_do import BizUserSite, BizUserWarehouse
from module_business.service.access_service import AccessService 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) result.scalars.return_value.all.return_value = (11, 13)
db = self._db_with_result(result) 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) self.assertEqual([11, 13], warehouse_ids)
statement = db.execute.await_args.args[0] statement = db.execute.await_args.args[0]
self._assert_statement_targets( sql = ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split())
statement, self.assertIn('JOIN biz_warehouse', sql)
BizUserWarehouse, self.assertIn('biz_user_warehouse.user_id = 8', sql)
BizUserWarehouse.warehouse_id, self.assertIn('biz_warehouse.test_batch_id IS NULL', sql)
((BizUserWarehouse.user_id, 8),),
)
async def test_list_user_site_ids_returns_scalar_ids(self) -> None: async def test_list_user_site_ids_returns_scalar_ids(self) -> None:
result = MagicMock() result = MagicMock()
result.scalars.return_value.all.return_value = (3, 5) result.scalars.return_value.all.return_value = (3, 5)
db = self._db_with_result(result) 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) self.assertEqual([3, 5], site_ids)
statement = db.execute.await_args.args[0] statement = db.execute.await_args.args[0]
self._assert_statement_targets( sql = ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split())
statement, self.assertIn('JOIN biz_customer_site', sql)
BizUserSite, self.assertIn('biz_user_site.user_id = 8', sql)
BizUserSite.site_id, self.assertIn('biz_customer_site.test_batch_id IS NULL', sql)
((BizUserSite.user_id, 8),),
)
async def test_replace_warehouse_users_replaces_complete_mapping(self) -> None: async def test_replace_warehouse_users_replaces_complete_mapping(self) -> None:
db = AsyncMock(spec=AsyncSession) db = AsyncMock(spec=AsyncSession)
@@ -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 import report_dao as report_dao_module
from module_business.dao.access_dao import AccessDao from module_business.dao.access_dao import AccessDao
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus 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 ( from module_business.entity.vo.report_vo import (
ConsumptionReportModel, ConsumptionReportModel,
ConsumptionReportQueryModel, ConsumptionReportQueryModel,
@@ -206,12 +207,12 @@ class TestReportPublicContract(unittest.TestCase):
report_dao = getattr(report_dao_module, 'ReportDao', None) report_dao = getattr(report_dao_module, 'ReportDao', None)
self.assertIsNotNone(report_dao, 'ReportDao 尚未实现') self.assertIsNotNone(report_dao, 'ReportDao 尚未实现')
expected_signatures = { expected_signatures = {
'get_pallet_inventory_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', 'warehouse_ids'), 'get_filling_report_list': ('db', 'query_object', 'scope', 'warehouse_ids'),
'get_shipment_report_list': ('db', 'query_object', 'warehouse_ids', 'site_ids'), 'get_shipment_report_list': ('db', 'query_object', 'scope', 'warehouse_ids', 'site_ids'),
'get_consumption_report_list': ('db', 'query_object', 'site_ids'), 'get_consumption_report_list': ('db', 'query_object', 'scope', 'site_ids'),
'get_return_report_list': ('db', 'query_object', 'site_ids', 'warehouse_ids'), 'get_return_report_list': ('db', 'query_object', 'scope', 'site_ids', 'warehouse_ids'),
'get_pallet_trace_list': ('db', 'query_object', 'warehouse_ids', 'site_ids'), 'get_pallet_trace_list': ('db', 'query_object', 'scope', 'warehouse_ids', 'site_ids'),
} }
for method_name, expected_parameters in expected_signatures.items(): for method_name, expected_parameters in expected_signatures.items():
@@ -237,7 +238,7 @@ class TestReportPublicContract(unittest.TestCase):
method = getattr(report_service, method_name, None) method = getattr(report_service, method_name, None)
self.assertIsNotNone(method, f'ReportService.{method_name} 尚未实现') self.assertIsNotNone(method, f'ReportService.{method_name} 尚未实现')
self.assertEqual( self.assertEqual(
('query_db', 'query_object', 'current_user'), ('query_db', 'query_object', 'current_user', 'test_scope'),
tuple(inspect.signature(method).parameters), tuple(inspect.signature(method).parameters),
) )
@@ -335,6 +336,7 @@ class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase):
hasNext=False, hasNext=False,
) )
paginate = AsyncMock(return_value=page) paginate = AsyncMock(return_value=page)
scope.setdefault('scope', TestBatchScope.formal())
with patch.object(PageUtil, 'paginate', new=paginate): with patch.object(PageUtil, 'paginate', new=paginate):
try: try:
result = await method(self.db, query_object, **scope) result = await method(self.db, query_object, **scope)
@@ -659,23 +661,24 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
dao_method = AsyncMock(return_value=page) dao_method = AsyncMock(return_value=page)
warehouse_scope = AsyncMock(return_value=[8, 7, 7]) warehouse_scope = AsyncMock(return_value=[8, 7, 7])
site_scope = AsyncMock(return_value=[10, 9, 9]) site_scope = AsyncMock(return_value=[10, 9, 9])
test_scope = TestBatchScope.formal()
with ( with (
patch.object(ReportDao, dao_name, new=dao_method), patch.object(ReportDao, dao_name, new=dao_method),
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope), patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
patch.object(AccessDao, 'list_user_site_ids', new=site_scope), patch.object(AccessDao, 'list_user_site_ids', new=site_scope),
): ):
try: 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: except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现数据范围和行转换') 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: 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: else:
warehouse_scope.assert_not_awaited() warehouse_scope.assert_not_awaited()
if 'site_ids' in expected_scope: 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: else:
site_scope.assert_not_awaited() site_scope.assert_not_awaited()
self.assertEqual(17, result.total) self.assertEqual(17, result.total)
@@ -779,6 +782,7 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
) )
for service_name, dao_name, query_object, expected_scope in cases: for service_name, dao_name, query_object, expected_scope in cases:
with self.subTest(service_name=service_name): with self.subTest(service_name=service_name):
test_scope = TestBatchScope.formal()
dao_method = AsyncMock( dao_method = AsyncMock(
return_value=PageModel( return_value=PageModel(
rows=[], rows=[],
@@ -796,13 +800,13 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
patch.object(AccessDao, 'list_user_site_ids', new=site_scope), patch.object(AccessDao, 'list_user_site_ids', new=site_scope),
): ):
try: 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: except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现管理员范围语义') self.fail(f'ReportService.{service_name} 尚未实现管理员范围语义')
warehouse_scope.assert_not_awaited() warehouse_scope.assert_not_awaited()
site_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( async def _assert_empty_page_without_dao(
self, self,
@@ -813,6 +817,7 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
site_ids: list[int] | None, site_ids: list[int] | None,
) -> None: ) -> None:
dao_method = AsyncMock() dao_method = AsyncMock()
test_scope = TestBatchScope.formal()
with ( with (
patch.object(ReportDao, dao_name, new=dao_method), patch.object(ReportDao, dao_name, new=dao_method),
patch.object( patch.object(
@@ -827,7 +832,7 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
), ),
): ):
try: 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: except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现空范围短路') 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=[])), patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[])),
): ):
try: 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: except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现单侧 OR 范围可见性') self.fail(f'ReportService.{service_name} 尚未实现单侧 OR 范围可见性')
dao_method.assert_awaited_once() dao_method.assert_awaited_once()
@@ -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()
@@ -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 () => { test('扫码组件声明交互契约并保持触控尺寸', async () => {
const source = await readFile(path.join(frontendRoot, 'src/components/PalletScanInput/index.vue'), 'utf8') const source = await readFile(path.join(frontendRoot, 'src/components/PalletScanInput/index.vue'), 'utf8')