功能:新增托盘业务报表接口

This commit is contained in:
焦龙言
2026-07-27 01:27:16 +08:00
parent c9180b3832
commit dfd720359a
4 changed files with 1810 additions and 0 deletions
@@ -0,0 +1,131 @@
"""业务报表接口。"""
from typing import Annotated
from fastapi import Query, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.interface_auth import UserInterfaceAuthDependency
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.entity.vo.report_vo import (
ConsumptionReportModel,
ConsumptionReportQueryModel,
FillingReportModel,
FillingReportQueryModel,
PalletInventoryReportModel,
PalletInventoryReportQueryModel,
PalletTraceReportModel,
PalletTraceReportQueryModel,
ReturnReportModel,
ReturnReportQueryModel,
ShipmentReportModel,
ShipmentReportQueryModel,
)
from module_business.service.report_service import ReportService
from utils.response_util import ResponseUtil
report_controller = APIRouterPro(
prefix='/report',
tags=['业务管理-只读报表'],
dependencies=[PreAuthDependency()],
)
@report_controller.get(
'/pallet-inventory/list',
summary='获取托盘库存报表分页列表接口',
response_model=PageResponseModel[PalletInventoryReportModel],
dependencies=[UserInterfaceAuthDependency('report:palletinventory:list')],
)
async def get_pallet_inventory_report_list(
request: Request,
query_object: Annotated[PalletInventoryReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReportService.get_pallet_inventory_list_services(query_db, query_object, current_user)
return ResponseUtil.success(model_content=result)
@report_controller.get(
'/filling/list',
summary='获取装填记录报表分页列表接口',
response_model=PageResponseModel[FillingReportModel],
dependencies=[UserInterfaceAuthDependency('report:filling:list')],
)
async def get_filling_report_list(
request: Request,
query_object: Annotated[FillingReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReportService.get_filling_report_list_services(query_db, query_object, current_user)
return ResponseUtil.success(model_content=result)
@report_controller.get(
'/shipment/list',
summary='获取发货出库报表分页列表接口',
response_model=PageResponseModel[ShipmentReportModel],
dependencies=[UserInterfaceAuthDependency('report:shipment:list')],
)
async def get_shipment_report_list(
request: Request,
query_object: Annotated[ShipmentReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReportService.get_shipment_report_list_services(query_db, query_object, current_user)
return ResponseUtil.success(model_content=result)
@report_controller.get(
'/consumption/list',
summary='获取产线消耗报表分页列表接口',
response_model=PageResponseModel[ConsumptionReportModel],
dependencies=[UserInterfaceAuthDependency('report:consumption:list')],
)
async def get_consumption_report_list(
request: Request,
query_object: Annotated[ConsumptionReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReportService.get_consumption_report_list_services(query_db, query_object, current_user)
return ResponseUtil.success(model_content=result)
@report_controller.get(
'/return/list',
summary='获取退回仓库报表分页列表接口',
response_model=PageResponseModel[ReturnReportModel],
dependencies=[UserInterfaceAuthDependency('report:return:list')],
)
async def get_return_report_list(
request: Request,
query_object: Annotated[ReturnReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReportService.get_return_report_list_services(query_db, query_object, current_user)
return ResponseUtil.success(model_content=result)
@report_controller.get(
'/pallet-trace/list',
summary='获取托盘追溯报表分页列表接口',
response_model=PageResponseModel[PalletTraceReportModel],
dependencies=[UserInterfaceAuthDependency('report:pallettrace:list')],
)
async def get_pallet_trace_report_list(
request: Request,
query_object: Annotated[PalletTraceReportQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await ReportService.get_pallet_trace_list_services(query_db, query_object, current_user)
return ResponseUtil.success(model_content=result)
@@ -0,0 +1,494 @@
from datetime import datetime, time, timedelta
from typing import Any
from sqlalchemy import ColumnElement, Select, false, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel
from module_admin.entity.do.user_do import SysUser
from module_business.entity.do.master_do import (
BizCustomer,
BizCustomerSite,
BizPallet,
BizProductionLine,
BizWarehouse,
)
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizPalletEvent,
BizPalletInventory,
BizReturnItem,
BizReturnOrder,
BizShipmentItem,
BizShipmentOrder,
BizUsageRecord,
)
from module_business.entity.vo.report_vo import (
ConsumptionReportModel,
ConsumptionReportQueryModel,
FillingReportModel,
FillingReportQueryModel,
PalletInventoryReportModel,
PalletInventoryReportQueryModel,
PalletTraceReportModel,
PalletTraceReportQueryModel,
ReturnReportModel,
ReturnReportQueryModel,
ShipmentReportModel,
ShipmentReportQueryModel,
)
from utils.page_util import PageUtil
class ReportDao:
"""六类只读业务报表数据库查询。"""
@staticmethod
def _time_conditions(
column: InstrumentedAttribute,
begin_time: str | None,
end_time: str | None,
) -> list[ColumnElement[bool]]:
conditions = []
if begin_time:
begin_at = datetime.combine(datetime.strptime(begin_time, '%Y-%m-%d').date(), time.min)
conditions.append(column >= begin_at)
if end_time:
end_at = datetime.combine(
datetime.strptime(end_time, '%Y-%m-%d').date() + timedelta(days=1),
time.min,
)
conditions.append(column < end_at)
return conditions
@staticmethod
def _scope_condition(
column: InstrumentedAttribute,
scope_ids: list[int] | None,
) -> ColumnElement[bool] | None:
if scope_ids is None:
return None
normalized_ids = sorted(set(scope_ids))
return column.in_(normalized_ids) if normalized_ids else false()
@classmethod
def _or_scope_conditions(
cls,
*column_scopes: tuple[InstrumentedAttribute, list[int] | None],
) -> list[ColumnElement[bool]]:
conditions = [
condition
for column, scope_ids in column_scopes
if (condition := cls._scope_condition(column, scope_ids)) is not None
]
return [or_(*conditions)] if conditions else []
@staticmethod
async def _paginate(
db: AsyncSession,
statement: Select,
query_object: Any,
) -> PageModel:
return await PageUtil.paginate(
db,
statement,
query_object.page_num,
query_object.page_size,
True,
)
@classmethod
async def get_pallet_inventory_list(
cls,
db: AsyncSession,
query_object: PalletInventoryReportQueryModel,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> PageModel[PalletInventoryReportModel]:
current_warehouse = aliased(BizWarehouse, name='current_warehouse')
current_site = aliased(BizCustomerSite, name='current_site')
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),
)
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.flow_state:
conditions.append(BizPalletInventory.flow_state == query_object.flow_state.value)
if query_object.warehouse_id:
conditions.append(BizPalletInventory.warehouse_id == query_object.warehouse_id)
if query_object.site_id:
conditions.append(BizPalletInventory.site_id == query_object.site_id)
if query_object.line_id:
conditions.append(BizPalletInventory.current_line_id == query_object.line_id)
if query_object.strain_id:
conditions.append(active_filling.strain_id == query_object.strain_id)
if query_object.batch_no:
conditions.append(active_filling.batch_no.like(f'%{query_object.batch_no}%'))
conditions.extend(
cls._time_conditions(
BizPalletInventory.updated_at,
query_object.begin_time,
query_object.end_time,
)
)
statement = (
select(
BizPalletInventory.pallet_id.label('pallet_id'),
BizPallet.pallet_code.label('pallet_code'),
BizPalletInventory.flow_state.label('flow_state'),
BizPalletInventory.warehouse_id.label('warehouse_id'),
current_warehouse.warehouse_name.label('warehouse_name'),
BizPalletInventory.site_id.label('site_id'),
current_site.site_name.label('site_name'),
BizPalletInventory.current_line_id.label('line_id'),
current_line.line_name.label('line_name'),
active_filling.strain_id.label('strain_id'),
active_filling.strain_code_snapshot.label('strain_code'),
active_filling.strain_name_snapshot.label('strain_name'),
active_filling.batch_no.label('batch_no'),
active_filling.initial_packages.label('initial_packages'),
BizPalletInventory.remaining_packages.label('remaining_packages'),
BizPalletInventory.updated_at.label('updated_at'),
)
.select_from(BizPalletInventory)
.join(BizPallet, BizPallet.pallet_id == BizPalletInventory.pallet_id)
.outerjoin(active_filling, active_filling.filling_id == BizPalletInventory.active_filling_id)
.outerjoin(current_warehouse, current_warehouse.warehouse_id == BizPalletInventory.warehouse_id)
.outerjoin(current_site, current_site.site_id == BizPalletInventory.site_id)
.outerjoin(current_line, current_line.line_id == BizPalletInventory.current_line_id)
.where(*conditions)
.order_by(BizPalletInventory.pallet_id)
)
return await cls._paginate(db, statement, query_object)
@classmethod
async def get_filling_report_list(
cls,
db: AsyncSession,
query_object: FillingReportQueryModel,
warehouse_ids: list[int] | None = None,
) -> PageModel[FillingReportModel]:
operator_user = aliased(SysUser, name='operator_user')
conditions = []
warehouse_scope = cls._scope_condition(BizFillingRecord.warehouse_id, warehouse_ids)
if warehouse_scope is not None:
conditions.append(warehouse_scope)
if query_object.warehouse_id:
conditions.append(BizFillingRecord.warehouse_id == query_object.warehouse_id)
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.strain_id:
conditions.append(BizFillingRecord.strain_id == query_object.strain_id)
if query_object.batch_no:
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
conditions.extend(
cls._time_conditions(
BizFillingRecord.operated_at,
query_object.begin_time,
query_object.end_time,
)
)
statement = (
select(
BizFillingRecord.filling_id.label('filling_id'),
BizFillingRecord.filling_no.label('filling_no'),
BizFillingRecord.operated_at.label('operated_at'),
BizFillingRecord.warehouse_id.label('warehouse_id'),
BizWarehouse.warehouse_name.label('warehouse_name'),
BizFillingRecord.pallet_id.label('pallet_id'),
BizPallet.pallet_code.label('pallet_code'),
BizFillingRecord.strain_id.label('strain_id'),
BizFillingRecord.strain_code_snapshot.label('strain_code_snapshot'),
BizFillingRecord.strain_name_snapshot.label('strain_name_snapshot'),
BizFillingRecord.batch_no.label('batch_no'),
BizFillingRecord.package_spec.label('package_spec'),
BizFillingRecord.initial_packages.label('initial_packages'),
BizFillingRecord.operator_id.label('operator_id'),
operator_user.user_name.label('operator_name'),
)
.select_from(BizFillingRecord)
.join(BizPallet, BizPallet.pallet_id == BizFillingRecord.pallet_id)
.outerjoin(BizWarehouse, BizWarehouse.warehouse_id == BizFillingRecord.warehouse_id)
.outerjoin(operator_user, operator_user.user_id == BizFillingRecord.operator_id)
.where(*conditions)
.order_by(BizFillingRecord.filling_id)
)
return await cls._paginate(db, statement, query_object)
@classmethod
async def get_shipment_report_list(
cls,
db: AsyncSession,
query_object: ShipmentReportQueryModel,
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 = []
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:
conditions.append(warehouse_scope)
if site_scope is not None:
conditions.append(site_scope)
if query_object.shipment_no:
conditions.append(BizShipmentOrder.shipment_no.like(f'%{query_object.shipment_no}%'))
if query_object.source_warehouse_id:
conditions.append(BizShipmentOrder.source_warehouse_id == query_object.source_warehouse_id)
if query_object.target_site_id:
conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id)
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.status:
conditions.append(BizShipmentOrder.status == query_object.status.value)
conditions.extend(
cls._time_conditions(
BizShipmentOrder.confirmed_at,
query_object.begin_time,
query_object.end_time,
)
)
# 报表行契约要求确认时间和正数包数,仅确认后的事实明细可形成报表行。
conditions.extend(
(
BizShipmentOrder.confirmed_at.is_not(None),
BizShipmentItem.package_qty.is_not(None),
)
)
statement = (
select(
BizShipmentOrder.shipment_id.label('shipment_id'),
BizShipmentOrder.shipment_no.label('shipment_no'),
BizShipmentOrder.confirmed_at.label('confirmed_at'),
BizShipmentOrder.source_warehouse_id.label('source_warehouse_id'),
source_warehouse.warehouse_name.label('source_warehouse_name'),
BizShipmentOrder.target_site_id.label('target_site_id'),
target_site.site_name.label('target_site_name'),
target_site.customer_id.label('customer_id'),
target_customer.customer_name.label('customer_name'),
BizShipmentItem.pallet_id.label('pallet_id'),
BizPallet.pallet_code.label('pallet_code'),
BizShipmentItem.active_filling_id.label('active_filling_id'),
BizShipmentItem.package_qty.label('package_qty'),
)
.select_from(BizShipmentOrder)
.join(BizShipmentItem, BizShipmentItem.shipment_id == BizShipmentOrder.shipment_id)
.join(BizPallet, BizPallet.pallet_id == BizShipmentItem.pallet_id)
.outerjoin(source_warehouse, source_warehouse.warehouse_id == BizShipmentOrder.source_warehouse_id)
.outerjoin(target_site, target_site.site_id == BizShipmentOrder.target_site_id)
.outerjoin(target_customer, target_customer.customer_id == target_site.customer_id)
.where(*conditions)
.order_by(BizShipmentOrder.shipment_id, BizShipmentItem.shipment_item_id)
)
return await cls._paginate(db, statement, query_object)
@classmethod
async def get_consumption_report_list(
cls,
db: AsyncSession,
query_object: ConsumptionReportQueryModel,
site_ids: list[int] | None = None,
) -> PageModel[ConsumptionReportModel]:
operator_user = aliased(SysUser, name='operator_user')
conditions = []
site_scope = cls._scope_condition(BizUsageRecord.site_id, site_ids)
if site_scope is not None:
conditions.append(site_scope)
if query_object.customer_id:
conditions.append(BizCustomerSite.customer_id == query_object.customer_id)
if query_object.site_id:
conditions.append(BizUsageRecord.site_id == query_object.site_id)
if query_object.line_id:
conditions.append(BizUsageRecord.line_id == query_object.line_id)
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.strain_id:
conditions.append(BizFillingRecord.strain_id == query_object.strain_id)
if query_object.batch_no:
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
conditions.extend(
cls._time_conditions(
BizUsageRecord.occurred_at,
query_object.begin_time,
query_object.end_time,
)
)
statement = (
select(
BizUsageRecord.usage_id.label('usage_id'),
BizUsageRecord.usage_no.label('usage_no'),
BizUsageRecord.occurred_at.label('occurred_at'),
BizCustomerSite.customer_id.label('customer_id'),
BizCustomer.customer_name.label('customer_name'),
BizUsageRecord.site_id.label('site_id'),
BizCustomerSite.site_name.label('site_name'),
BizUsageRecord.line_id.label('line_id'),
BizProductionLine.line_name.label('line_name'),
BizUsageRecord.pallet_id.label('pallet_id'),
BizPallet.pallet_code.label('pallet_code'),
BizFillingRecord.strain_id.label('strain_id'),
BizFillingRecord.strain_code_snapshot.label('strain_code'),
BizFillingRecord.strain_name_snapshot.label('strain_name'),
BizFillingRecord.batch_no.label('batch_no'),
BizUsageRecord.used_packages.label('used_packages'),
BizUsageRecord.remaining_packages_after.label('remaining_packages_after'),
BizUsageRecord.operator_id.label('operator_id'),
operator_user.user_name.label('operator_name'),
)
.select_from(BizUsageRecord)
.join(BizPallet, BizPallet.pallet_id == BizUsageRecord.pallet_id)
.join(BizFillingRecord, BizFillingRecord.filling_id == BizUsageRecord.active_filling_id)
.join(BizCustomerSite, BizCustomerSite.site_id == BizUsageRecord.site_id)
.join(BizProductionLine, BizProductionLine.line_id == BizUsageRecord.line_id)
.outerjoin(BizCustomer, BizCustomer.customer_id == BizCustomerSite.customer_id)
.outerjoin(operator_user, operator_user.user_id == BizUsageRecord.operator_id)
.where(*conditions)
.order_by(BizUsageRecord.usage_id)
)
return await cls._paginate(db, statement, query_object)
@classmethod
async def get_return_report_list(
cls,
db: AsyncSession,
query_object: ReturnReportQueryModel,
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),
)
if query_object.return_no:
conditions.append(BizReturnOrder.return_no.like(f'%{query_object.return_no}%'))
if query_object.source_site_id:
conditions.append(BizReturnOrder.source_site_id == query_object.source_site_id)
if query_object.target_warehouse_id:
conditions.append(BizReturnOrder.target_warehouse_id == query_object.target_warehouse_id)
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.status:
conditions.append(BizReturnOrder.status == query_object.status.value)
conditions.extend(
cls._time_conditions(
BizReturnOrder.requested_at,
query_object.begin_time,
query_object.end_time,
)
)
statement = (
select(
BizReturnOrder.return_id.label('return_id'),
BizReturnOrder.return_no.label('return_no'),
BizReturnOrder.status.label('status'),
BizReturnOrder.requested_at.label('requested_at'),
BizReturnOrder.confirmed_at.label('confirmed_at'),
BizReturnOrder.source_site_id.label('source_site_id'),
source_site.site_name.label('source_site_name'),
BizReturnOrder.target_warehouse_id.label('target_warehouse_id'),
target_warehouse.warehouse_name.label('target_warehouse_name'),
BizReturnItem.pallet_id.label('pallet_id'),
BizPallet.pallet_code.label('pallet_code'),
BizReturnItem.active_filling_id_snapshot.label('active_filling_id_snapshot'),
BizReturnItem.remaining_packages_snapshot.label('remaining_packages_snapshot'),
)
.select_from(BizReturnOrder)
.join(BizReturnItem, BizReturnItem.return_id == BizReturnOrder.return_id)
.join(BizPallet, BizPallet.pallet_id == BizReturnItem.pallet_id)
.outerjoin(source_site, source_site.site_id == BizReturnOrder.source_site_id)
.outerjoin(target_warehouse, target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id)
.where(*conditions)
.order_by(BizReturnOrder.return_id, BizReturnItem.return_item_id)
)
return await cls._paginate(db, statement, query_object)
@classmethod
async def get_pallet_trace_list(
cls,
db: AsyncSession,
query_object: PalletTraceReportQueryModel,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> PageModel[PalletTraceReportModel]:
before_warehouse = aliased(BizWarehouse, name='before_warehouse')
after_warehouse = aliased(BizWarehouse, name='after_warehouse')
before_site = aliased(BizCustomerSite, name='before_site')
after_site = aliased(BizCustomerSite, name='after_site')
before_line = aliased(BizProductionLine, name='before_line')
after_line = aliased(BizProductionLine, name='after_line')
operator_user = aliased(SysUser, name='operator_user')
conditions = cls._or_scope_conditions(
(BizPalletEvent.before_warehouse_id, warehouse_ids),
(BizPalletEvent.after_warehouse_id, warehouse_ids),
(BizPalletEvent.before_site_id, site_ids),
(BizPalletEvent.after_site_id, site_ids),
)
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.event_type:
conditions.append(BizPalletEvent.event_type == query_object.event_type.value)
conditions.extend(
cls._time_conditions(
BizPalletEvent.occurred_at,
query_object.begin_time,
query_object.end_time,
)
)
statement = (
select(
BizPalletEvent.event_id.label('event_id'),
BizPalletEvent.event_seq.label('event_seq'),
BizPalletEvent.event_type.label('event_type'),
BizPalletEvent.source_type.label('source_type'),
BizPalletEvent.source_id.label('source_id'),
BizPalletEvent.pallet_id.label('pallet_id'),
BizPallet.pallet_code.label('pallet_code'),
BizPalletEvent.before_state.label('before_state'),
BizPalletEvent.after_state.label('after_state'),
BizPalletEvent.before_warehouse_id.label('before_warehouse_id'),
before_warehouse.warehouse_name.label('before_warehouse_name'),
BizPalletEvent.before_site_id.label('before_site_id'),
before_site.site_name.label('before_site_name'),
BizPalletEvent.before_line_id.label('before_line_id'),
before_line.line_name.label('before_line_name'),
BizPalletEvent.after_warehouse_id.label('after_warehouse_id'),
after_warehouse.warehouse_name.label('after_warehouse_name'),
BizPalletEvent.after_site_id.label('after_site_id'),
after_site.site_name.label('after_site_name'),
BizPalletEvent.after_line_id.label('after_line_id'),
after_line.line_name.label('after_line_name'),
BizPalletEvent.before_remaining_packages.label('before_remaining_packages'),
BizPalletEvent.after_remaining_packages.label('after_remaining_packages'),
BizPalletEvent.operator_id.label('operator_id'),
operator_user.user_name.label('operator_name'),
BizPalletEvent.occurred_at.label('occurred_at'),
)
.select_from(BizPalletEvent)
.join(BizPallet, BizPallet.pallet_id == BizPalletEvent.pallet_id)
.outerjoin(before_warehouse, before_warehouse.warehouse_id == BizPalletEvent.before_warehouse_id)
.outerjoin(after_warehouse, after_warehouse.warehouse_id == BizPalletEvent.after_warehouse_id)
.outerjoin(before_site, before_site.site_id == BizPalletEvent.before_site_id)
.outerjoin(after_site, after_site.site_id == BizPalletEvent.after_site_id)
.outerjoin(before_line, before_line.line_id == BizPalletEvent.before_line_id)
.outerjoin(after_line, after_line.line_id == BizPalletEvent.after_line_id)
.outerjoin(operator_user, operator_user.user_id == BizPalletEvent.operator_id)
.where(*conditions)
.order_by(BizPalletEvent.pallet_id, BizPalletEvent.event_seq, BizPalletEvent.event_id)
)
return await cls._paginate(db, statement, query_object)
@@ -0,0 +1,196 @@
from typing import Any, TypeVar
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
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.entity.vo.report_vo import (
ConsumptionReportModel,
ConsumptionReportQueryModel,
FillingReportModel,
FillingReportQueryModel,
PalletInventoryReportModel,
PalletInventoryReportQueryModel,
PalletTraceReportModel,
PalletTraceReportQueryModel,
ReturnReportModel,
ReturnReportQueryModel,
ShipmentReportModel,
ShipmentReportQueryModel,
)
RowModelT = TypeVar('RowModelT', bound=BaseModel)
class ReportService:
"""六类只读业务报表服务。"""
@staticmethod
def _is_admin(current_user: CurrentUserModel) -> bool:
return bool(current_user.user and current_user.user.admin)
@classmethod
def _user_id(cls, current_user: CurrentUserModel) -> int:
if current_user.user is None or current_user.user.user_id is None:
raise ServiceException(message='当前用户信息无效')
return current_user.user.user_id
@classmethod
async def _warehouse_scope(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
) -> 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))
return sorted(set(warehouse_ids))
@classmethod
async def _site_scope(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
) -> 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))
return sorted(set(site_ids))
@classmethod
async def _scopes(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
) -> 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),
)
@staticmethod
def _empty_page(query_object: Any) -> PageModel:
return PageModel(
rows=[],
pageNum=query_object.page_num,
pageSize=query_object.page_size,
total=0,
hasNext=False,
)
@staticmethod
def _convert_page(page: PageModel, row_model: type[RowModelT]) -> PageModel[RowModelT]:
rows = [row_model.model_validate(row) for row in page.rows]
return page.model_copy(update={'rows': rows})
@classmethod
async def get_pallet_inventory_list_services(
cls,
query_db: AsyncSession,
query_object: PalletInventoryReportQueryModel,
current_user: CurrentUserModel,
) -> PageModel[PalletInventoryReportModel]:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
if warehouse_ids == [] and site_ids == []:
return cls._empty_page(query_object)
page = await ReportDao.get_pallet_inventory_list(
query_db,
query_object,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
return cls._convert_page(page, PalletInventoryReportModel)
@classmethod
async def get_filling_report_list_services(
cls,
query_db: AsyncSession,
query_object: FillingReportQueryModel,
current_user: CurrentUserModel,
) -> PageModel[FillingReportModel]:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if warehouse_ids == []:
return cls._empty_page(query_object)
page = await ReportDao.get_filling_report_list(
query_db,
query_object,
warehouse_ids=warehouse_ids,
)
return cls._convert_page(page, FillingReportModel)
@classmethod
async def get_shipment_report_list_services(
cls,
query_db: AsyncSession,
query_object: ShipmentReportQueryModel,
current_user: CurrentUserModel,
) -> PageModel[ShipmentReportModel]:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
if warehouse_ids == [] or site_ids == []:
return cls._empty_page(query_object)
page = await ReportDao.get_shipment_report_list(
query_db,
query_object,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
return cls._convert_page(page, ShipmentReportModel)
@classmethod
async def get_consumption_report_list_services(
cls,
query_db: AsyncSession,
query_object: ConsumptionReportQueryModel,
current_user: CurrentUserModel,
) -> PageModel[ConsumptionReportModel]:
site_ids = await cls._site_scope(query_db, current_user)
if site_ids == []:
return cls._empty_page(query_object)
page = await ReportDao.get_consumption_report_list(
query_db,
query_object,
site_ids=site_ids,
)
return cls._convert_page(page, ConsumptionReportModel)
@classmethod
async def get_return_report_list_services(
cls,
query_db: AsyncSession,
query_object: ReturnReportQueryModel,
current_user: CurrentUserModel,
) -> PageModel[ReturnReportModel]:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
if warehouse_ids == [] and site_ids == []:
return cls._empty_page(query_object)
page = await ReportDao.get_return_report_list(
query_db,
query_object,
site_ids=site_ids,
warehouse_ids=warehouse_ids,
)
return cls._convert_page(page, ReturnReportModel)
@classmethod
async def get_pallet_trace_list_services(
cls,
query_db: AsyncSession,
query_object: PalletTraceReportQueryModel,
current_user: CurrentUserModel,
) -> PageModel[PalletTraceReportModel]:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
if warehouse_ids == [] and site_ids == []:
return cls._empty_page(query_object)
page = await ReportDao.get_pallet_trace_list(
query_db,
query_object,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
return cls._convert_page(page, PalletTraceReportModel)
@@ -0,0 +1,989 @@
import ast
import importlib.util
import inspect
import unittest
from collections.abc import Callable
from datetime import datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.routing import APIRoute
from pydantic import BaseModel
from sqlalchemy import Select
from sqlalchemy.dialects import mysql, postgresql
from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.interface_auth import CheckUserInterfaceAuth
from common.aspect.pre_auth import PreAuth
from common.vo import PageModel, PageResponseModel
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
from module_admin.service.login_service import LoginService
from module_business.controller import report_controller as report_controller_module
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.entity.vo.report_vo import (
ConsumptionReportModel,
ConsumptionReportQueryModel,
FillingReportModel,
FillingReportQueryModel,
PalletInventoryReportModel,
PalletInventoryReportQueryModel,
PalletTraceReportModel,
PalletTraceReportQueryModel,
ReturnReportModel,
ReturnReportQueryModel,
ShipmentReportModel,
ShipmentReportQueryModel,
)
from module_business.service import report_service as report_service_module
from server import create_app
from utils.page_util import PageUtil
ReportDao = getattr(report_dao_module, 'ReportDao', None)
ReportService = getattr(report_service_module, 'ReportService', None)
report_controller = getattr(report_controller_module, 'report_controller', None)
def make_user(user_id: int = 7) -> CurrentUserModel:
return CurrentUserModel(
permissions=[],
roles=[],
user=UserInfoModel(userId=user_id, userName='admin' if user_id == 1 else f'user-{user_id}'),
)
def report_row(model_type: type[BaseModel]) -> dict[str, Any]:
now = datetime(2026, 7, 26, 10, 30)
rows = {
PalletInventoryReportModel: {
'pallet_id': 1,
'pallet_code': 'P-001',
'flow_state': PalletFlowState.FILLED_IN_WAREHOUSE,
'warehouse_id': 7,
'warehouse_name': '一号仓',
'site_id': None,
'site_name': None,
'line_id': None,
'line_name': None,
'strain_id': 11,
'strain_code': 'S-011',
'strain_name': '香菇 11',
'batch_no': 'B-202607',
'initial_packages': 20,
'remaining_packages': 12,
'updated_at': now,
},
FillingReportModel: {
'filling_id': 2,
'filling_no': 'FL-002',
'operated_at': now,
'warehouse_id': 7,
'warehouse_name': '一号仓',
'pallet_id': 1,
'pallet_code': 'P-001',
'strain_id': 11,
'strain_code_snapshot': 'S-011',
'strain_name_snapshot': '香菇 11',
'batch_no': 'B-202607',
'package_spec': '20kg',
'initial_packages': 20,
'operator_id': 7,
'operator_name': 'user-7',
},
ShipmentReportModel: {
'shipment_id': 3,
'shipment_no': 'SH-003',
'confirmed_at': now,
'source_warehouse_id': 7,
'source_warehouse_name': '一号仓',
'target_site_id': 9,
'target_site_name': '一号现场',
'customer_id': 3,
'customer_name': '一号客户',
'pallet_id': 1,
'pallet_code': 'P-001',
'active_filling_id': 2,
'package_qty': 12,
},
ConsumptionReportModel: {
'usage_id': 4,
'usage_no': 'US-004',
'occurred_at': now,
'customer_id': 3,
'customer_name': '一号客户',
'site_id': 9,
'site_name': '一号现场',
'line_id': 4,
'line_name': '一号线',
'pallet_id': 1,
'pallet_code': 'P-001',
'strain_id': 11,
'strain_code': 'S-011',
'strain_name': '香菇 11',
'batch_no': 'B-202607',
'used_packages': 3,
'remaining_packages_after': 9,
'operator_id': 7,
'operator_name': 'user-7',
},
ReturnReportModel: {
'return_id': 5,
'return_no': 'RT-005',
'status': ReturnStatus.REQUESTED,
'requested_at': now,
'confirmed_at': None,
'source_site_id': 9,
'source_site_name': '一号现场',
'target_warehouse_id': 7,
'target_warehouse_name': '一号仓',
'pallet_id': 1,
'pallet_code': 'P-001',
'active_filling_id_snapshot': 2,
'remaining_packages_snapshot': 9,
},
PalletTraceReportModel: {
'event_id': 6,
'event_seq': 3,
'event_type': PalletEventType.USAGE_RECORDED,
'source_type': 'USAGE',
'source_id': 4,
'pallet_id': 1,
'pallet_code': 'P-001',
'before_state': PalletFlowState.AT_SITE_UNUSED,
'after_state': PalletFlowState.AT_SITE_PARTIAL,
'before_warehouse_id': None,
'before_warehouse_name': None,
'before_site_id': 9,
'before_site_name': '一号现场',
'before_line_id': None,
'before_line_name': None,
'after_warehouse_id': None,
'after_warehouse_name': None,
'after_site_id': 9,
'after_site_name': '一号现场',
'after_line_id': 4,
'after_line_name': '一号线',
'before_remaining_packages': 12,
'after_remaining_packages': 9,
'operator_id': 7,
'operator_name': 'user-7',
'occurred_at': now,
},
}
return rows[model_type]
class TestReportBootstrap(unittest.TestCase):
"""报表三层模块必须能够被项目自动发现。"""
def test_report_modules_exist(self) -> None:
module_names = (
'module_business.dao.report_dao',
'module_business.service.report_service',
'module_business.controller.report_controller',
)
for module_name in module_names:
with self.subTest(module_name=module_name):
self.assertIsNotNone(importlib.util.find_spec(module_name), f'{module_name} 尚未实现')
def test_report_controller_router_exists(self) -> None:
self.assertIsNotNone(report_controller, 'report_controller 路由尚未实现')
class TestReportPublicContract(unittest.TestCase):
def test_dao_exposes_exact_public_methods_and_signatures(self) -> None:
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'),
}
for method_name, expected_parameters in expected_signatures.items():
with self.subTest(method_name=method_name):
method = getattr(report_dao, method_name, None)
self.assertIsNotNone(method, f'ReportDao.{method_name} 尚未实现')
self.assertEqual(expected_parameters, tuple(inspect.signature(method).parameters))
def test_service_exposes_exact_public_methods_and_signatures(self) -> None:
report_service = getattr(report_service_module, 'ReportService', None)
self.assertIsNotNone(report_service, 'ReportService 尚未实现')
method_names = (
'get_pallet_inventory_list_services',
'get_filling_report_list_services',
'get_shipment_report_list_services',
'get_consumption_report_list_services',
'get_return_report_list_services',
'get_pallet_trace_list_services',
)
for method_name in method_names:
with self.subTest(method_name=method_name):
method = getattr(report_service, method_name, None)
self.assertIsNotNone(method, f'ReportService.{method_name} 尚未实现')
self.assertEqual(
('query_db', 'query_object', 'current_user'),
tuple(inspect.signature(method).parameters),
)
def test_dao_and_service_have_no_write_calls(self) -> None:
forbidden_calls = {
'add',
'add_all',
'commit',
'delete',
'execute_delete',
'execute_update',
'flush',
'rollback',
'update',
}
for module in (report_dao_module, report_service_module):
with self.subTest(module=module.__name__):
tree = ast.parse(inspect.getsource(module))
called_attributes = {
node.func.attr
for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
}
self.assertTrue(forbidden_calls.isdisjoint(called_attributes))
@unittest.skipUnless(ReportDao is not None, 'ReportDao 尚未实现')
class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
async def _capture_statement(
self,
method: Callable[..., Any],
query_object: Any,
**scope: Any,
) -> Select:
page = PageModel(
rows=[],
pageNum=query_object.page_num,
pageSize=query_object.page_size,
total=0,
hasNext=False,
)
paginate = AsyncMock(return_value=page)
with patch.object(PageUtil, 'paginate', new=paginate):
try:
result = await method(self.db, query_object, **scope)
except NotImplementedError:
self.fail(f'{method.__name__} 尚未构造只读分页查询')
self.assertIs(page, result)
paginate.assert_awaited_once()
args = paginate.await_args.args
self.assertIs(self.db, args[0])
self.assertEqual(query_object.page_num, args[2])
self.assertEqual(query_object.page_size, args[3])
self.assertIs(True, args[4])
return args[1]
def _assert_compiles_for_supported_databases(self, statement: Select) -> tuple[str, str]:
compiled = tuple(
' '.join(str(statement.compile(dialect=dialect, compile_kwargs={'literal_binds': True})).lower().split())
for dialect in (mysql.dialect(), postgresql.dialect())
)
self.assertTrue(all(sql.startswith('select ') for sql in compiled))
return compiled
def _assert_labels(self, statement: Select, row_model: type[BaseModel]) -> None:
self.assertEqual(tuple(row_model.model_fields), tuple(column.key for column in statement.selected_columns))
async def test_inventory_query_uses_current_facts_snapshots_all_filters_and_or_scope(self) -> None:
query = PalletInventoryReportQueryModel(
pageNum=2,
pageSize=7,
palletCode='P-001',
flowState=PalletFlowState.AT_SITE_PARTIAL,
warehouseId=7,
siteId=9,
lineId=4,
strainId=11,
batchNo='B-202607',
beginTime='2026-07-01',
endTime='2026-07-31',
)
statement = await self._capture_statement(
ReportDao.get_pallet_inventory_list,
query,
warehouse_ids=[8, 7, 7],
site_ids=[10, 9, 9],
)
self._assert_labels(statement, PalletInventoryReportModel)
for sql in self._assert_compiles_for_supported_databases(statement):
self.assertIn('from biz_pallet_inventory', sql)
self.assertIn('join biz_pallet ', sql)
self.assertIn('left outer join biz_filling_record as active_filling', sql)
self.assertIn('active_filling.strain_code_snapshot as strain_code', sql)
self.assertIn('active_filling.strain_name_snapshot as strain_name', sql)
self.assertNotIn('biz_strain', sql)
self.assertIn(
'(biz_pallet_inventory.warehouse_id in (7, 8) or biz_pallet_inventory.site_id in (9, 10))',
sql,
)
for column in (
'pallet_code',
'flow_state',
'warehouse_id',
'site_id',
'current_line_id',
'strain_id',
'batch_no',
):
self.assertIn(column, sql)
self.assertIn('biz_pallet_inventory.updated_at >=', sql)
self.assertIn('biz_pallet_inventory.updated_at <', sql)
self.assertIn('order by biz_pallet_inventory.pallet_id', sql)
async def test_filling_query_uses_snapshot_columns_all_filters_and_warehouse_scope(self) -> None:
query = FillingReportQueryModel(
pageNum=3,
pageSize=5,
warehouseId=7,
palletCode='P-001',
strainId=11,
batchNo='B-202607',
beginTime='2026-07-01',
endTime='2026-07-31',
)
statement = await self._capture_statement(
ReportDao.get_filling_report_list,
query,
warehouse_ids=[8, 7, 7],
)
self._assert_labels(statement, FillingReportModel)
for sql in self._assert_compiles_for_supported_databases(statement):
self.assertIn('from biz_filling_record', sql)
self.assertIn('join biz_pallet ', sql)
self.assertIn('join biz_warehouse ', sql)
self.assertIn('strain_code_snapshot', sql)
self.assertIn('strain_name_snapshot', sql)
self.assertNotIn('biz_strain', sql)
self.assertIn('biz_filling_record.warehouse_id in (7, 8)', sql)
for column in ('warehouse_id', 'pallet_code', 'strain_id', 'batch_no'):
self.assertIn(column, sql)
self.assertIn('biz_filling_record.operated_at >=', sql)
self.assertIn('biz_filling_record.operated_at <', sql)
self.assertIn('order by biz_filling_record.filling_id', sql)
async def test_shipment_query_is_one_row_per_item_uses_snapshots_and_and_scope(self) -> None:
query = ShipmentReportQueryModel(
shipmentNo='SH-001',
sourceWarehouseId=7,
targetSiteId=9,
palletCode='P-001',
status=DocumentStatus.CONFIRMED,
beginTime='2026-07-01',
endTime='2026-07-31',
)
statement = await self._capture_statement(
ReportDao.get_shipment_report_list,
query,
warehouse_ids=[8, 7],
site_ids=[10, 9],
)
self._assert_labels(statement, ShipmentReportModel)
for sql in self._assert_compiles_for_supported_databases(statement):
self.assertIn('from biz_shipment_order', sql)
self.assertIn('join biz_shipment_item', sql)
self.assertIn('biz_shipment_item.active_filling_id as active_filling_id', sql)
self.assertIn('biz_shipment_item.package_qty as package_qty', sql)
self.assertIn('biz_shipment_order.source_warehouse_id in (7, 8)', sql)
self.assertIn('biz_shipment_order.target_site_id in (9, 10)', sql)
self.assertNotIn(
'source_warehouse_id in (7, 8) or biz_shipment_order.target_site_id',
sql,
)
for column in ('shipment_no', 'source_warehouse_id', 'target_site_id', 'pallet_code', 'status'):
self.assertIn(column, sql)
self.assertIn('biz_shipment_order.confirmed_at >=', sql)
self.assertIn('biz_shipment_order.confirmed_at <', sql)
self.assertIn(
'order by biz_shipment_order.shipment_id, biz_shipment_item.shipment_item_id',
sql,
)
async def test_consumption_query_uses_usage_filling_snapshots_all_filters_and_site_scope(self) -> None:
query = ConsumptionReportQueryModel(
customerId=3,
siteId=9,
lineId=4,
palletCode='P-001',
strainId=11,
batchNo='B-202607',
beginTime='2026-07-01',
endTime='2026-07-31',
)
statement = await self._capture_statement(
ReportDao.get_consumption_report_list,
query,
site_ids=[10, 9, 9],
)
self._assert_labels(statement, ConsumptionReportModel)
for sql in self._assert_compiles_for_supported_databases(statement):
self.assertIn('from biz_usage_record', sql)
self.assertIn(
'join biz_filling_record on biz_filling_record.filling_id = biz_usage_record.active_filling_id',
sql,
)
self.assertIn('biz_filling_record.strain_code_snapshot as strain_code', sql)
self.assertIn('biz_filling_record.strain_name_snapshot as strain_name', sql)
self.assertNotIn('biz_strain', sql)
self.assertIn('biz_usage_record.site_id in (9, 10)', sql)
for column in ('customer_id', 'site_id', 'line_id', 'pallet_code', 'strain_id', 'batch_no'):
self.assertIn(column, sql)
self.assertIn('biz_usage_record.occurred_at >=', sql)
self.assertIn('biz_usage_record.occurred_at <', sql)
self.assertIn('order by biz_usage_record.usage_id', sql)
async def test_return_query_is_one_row_per_item_uses_snapshots_and_or_scope(self) -> None:
query = ReturnReportQueryModel(
returnNo='RT-001',
sourceSiteId=9,
targetWarehouseId=7,
palletCode='P-001',
status=ReturnStatus.REQUESTED,
beginTime='2026-07-01',
endTime='2026-07-31',
)
statement = await self._capture_statement(
ReportDao.get_return_report_list,
query,
site_ids=[10, 9],
warehouse_ids=[8, 7],
)
self._assert_labels(statement, ReturnReportModel)
for sql in self._assert_compiles_for_supported_databases(statement):
self.assertIn('from biz_return_order', sql)
self.assertIn('join biz_return_item', sql)
self.assertIn('biz_return_item.active_filling_id_snapshot as active_filling_id_snapshot', sql)
self.assertIn('biz_return_item.remaining_packages_snapshot as remaining_packages_snapshot', sql)
self.assertIn(
'(biz_return_order.source_site_id in (9, 10) or biz_return_order.target_warehouse_id in (7, 8))',
sql,
)
for column in ('return_no', 'source_site_id', 'target_warehouse_id', 'pallet_code', 'status'):
self.assertIn(column, sql)
self.assertIn('biz_return_order.requested_at >=', sql)
self.assertIn('biz_return_order.requested_at <', sql)
self.assertIn('order by biz_return_order.return_id, biz_return_item.return_item_id', sql)
async def test_trace_query_reads_event_history_aliases_all_filters_and_event_scope(self) -> None:
query = PalletTraceReportQueryModel(
palletCode='P-001',
eventType=PalletEventType.USAGE_RECORDED,
beginTime='2026-07-01',
endTime='2026-07-31',
)
statement = await self._capture_statement(
ReportDao.get_pallet_trace_list,
query,
warehouse_ids=[8, 7],
site_ids=[10, 9],
)
self._assert_labels(statement, PalletTraceReportModel)
for sql in self._assert_compiles_for_supported_databases(statement):
self.assertIn('from biz_pallet_event', sql)
self.assertNotIn('biz_pallet_inventory', sql)
self.assertNotIn('sys_oper_log', sql)
for column in (
'before_state',
'after_state',
'before_warehouse_id',
'after_warehouse_id',
'before_site_id',
'after_site_id',
'before_line_id',
'after_line_id',
'before_remaining_packages',
'after_remaining_packages',
'event_seq',
'event_type',
'source_type',
'source_id',
'operator_id',
'occurred_at',
):
self.assertIn(f'biz_pallet_event.{column}', sql)
self.assertIn('biz_pallet_event.before_warehouse_id in (7, 8)', sql)
self.assertIn('biz_pallet_event.after_warehouse_id in (7, 8)', sql)
self.assertIn('biz_pallet_event.before_site_id in (9, 10)', sql)
self.assertIn('biz_pallet_event.after_site_id in (9, 10)', sql)
self.assertIn('biz_pallet_event.occurred_at >=', sql)
self.assertIn('biz_pallet_event.occurred_at <', sql)
self.assertIn(
'order by biz_pallet_event.pallet_id, biz_pallet_event.event_seq, biz_pallet_event.event_id',
sql,
)
@unittest.skipUnless(ReportService is not None, 'ReportService 尚未实现')
class TestReportServices(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.user = make_user()
async def _assert_scoped_conversion(
self,
service_name: str,
dao_name: str,
query_object: Any,
row_model: type[BaseModel],
expected_scope: dict[str, list[int] | None],
) -> None:
page = PageModel(
rows=[report_row(row_model)],
pageNum=query_object.page_num,
pageSize=query_object.page_size,
total=17,
hasNext=True,
)
dao_method = AsyncMock(return_value=page)
warehouse_scope = AsyncMock(return_value=[8, 7, 7])
site_scope = AsyncMock(return_value=[10, 9, 9])
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)
except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现数据范围和行转换')
dao_method.assert_awaited_once_with(self.db, query_object, **expected_scope)
if 'warehouse_ids' in expected_scope:
warehouse_scope.assert_awaited_once_with(self.db, 7)
else:
warehouse_scope.assert_not_awaited()
if 'site_ids' in expected_scope:
site_scope.assert_awaited_once_with(self.db, 7)
else:
site_scope.assert_not_awaited()
self.assertEqual(17, result.total)
self.assertEqual(query_object.page_num, result.page_num)
self.assertEqual(query_object.page_size, result.page_size)
self.assertTrue(result.has_next)
self.assertIsInstance(result.rows[0], row_model)
async def test_inventory_service_pushes_both_scopes_and_converts_page(self) -> None:
await self._assert_scoped_conversion(
'get_pallet_inventory_list_services',
'get_pallet_inventory_list',
PalletInventoryReportQueryModel(pageNum=2, pageSize=7),
PalletInventoryReportModel,
{'warehouse_ids': [7, 8], 'site_ids': [9, 10]},
)
async def test_filling_service_pushes_warehouse_scope_and_converts_page(self) -> None:
await self._assert_scoped_conversion(
'get_filling_report_list_services',
'get_filling_report_list',
FillingReportQueryModel(pageNum=2, pageSize=7),
FillingReportModel,
{'warehouse_ids': [7, 8]},
)
async def test_shipment_service_pushes_both_scopes_and_converts_page(self) -> None:
await self._assert_scoped_conversion(
'get_shipment_report_list_services',
'get_shipment_report_list',
ShipmentReportQueryModel(pageNum=2, pageSize=7),
ShipmentReportModel,
{'warehouse_ids': [7, 8], 'site_ids': [9, 10]},
)
async def test_consumption_service_pushes_site_scope_and_converts_page(self) -> None:
await self._assert_scoped_conversion(
'get_consumption_report_list_services',
'get_consumption_report_list',
ConsumptionReportQueryModel(pageNum=2, pageSize=7),
ConsumptionReportModel,
{'site_ids': [9, 10]},
)
async def test_return_service_pushes_both_scopes_and_converts_page(self) -> None:
await self._assert_scoped_conversion(
'get_return_report_list_services',
'get_return_report_list',
ReturnReportQueryModel(pageNum=2, pageSize=7),
ReturnReportModel,
{'site_ids': [9, 10], 'warehouse_ids': [7, 8]},
)
async def test_trace_service_pushes_both_scopes_and_converts_page(self) -> None:
await self._assert_scoped_conversion(
'get_pallet_trace_list_services',
'get_pallet_trace_list',
PalletTraceReportQueryModel(pageNum=2, pageSize=7),
PalletTraceReportModel,
{'warehouse_ids': [7, 8], 'site_ids': [9, 10]},
)
async def test_admin_skips_scope_queries_and_passes_none(self) -> None:
cases = (
(
'get_pallet_inventory_list_services',
'get_pallet_inventory_list',
PalletInventoryReportQueryModel(),
{'warehouse_ids': None, 'site_ids': None},
),
(
'get_filling_report_list_services',
'get_filling_report_list',
FillingReportQueryModel(),
{'warehouse_ids': None},
),
(
'get_shipment_report_list_services',
'get_shipment_report_list',
ShipmentReportQueryModel(),
{'warehouse_ids': None, 'site_ids': None},
),
(
'get_consumption_report_list_services',
'get_consumption_report_list',
ConsumptionReportQueryModel(),
{'site_ids': None},
),
(
'get_return_report_list_services',
'get_return_report_list',
ReturnReportQueryModel(),
{'site_ids': None, 'warehouse_ids': None},
),
(
'get_pallet_trace_list_services',
'get_pallet_trace_list',
PalletTraceReportQueryModel(),
{'warehouse_ids': None, 'site_ids': None},
),
)
for service_name, dao_name, query_object, expected_scope in cases:
with self.subTest(service_name=service_name):
dao_method = AsyncMock(
return_value=PageModel(
rows=[],
pageNum=1,
pageSize=10,
total=0,
hasNext=False,
)
)
warehouse_scope = AsyncMock()
site_scope = AsyncMock()
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:
await getattr(ReportService, service_name)(self.db, query_object, make_user(1))
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)
async def _assert_empty_page_without_dao(
self,
service_name: str,
dao_name: str,
query_object: Any,
warehouse_ids: list[int] | None,
site_ids: list[int] | None,
) -> None:
dao_method = AsyncMock()
with (
patch.object(ReportDao, dao_name, new=dao_method),
patch.object(
AccessDao,
'list_user_warehouse_ids',
new=AsyncMock(return_value=warehouse_ids),
),
patch.object(
AccessDao,
'list_user_site_ids',
new=AsyncMock(return_value=site_ids),
),
):
try:
result = await getattr(ReportService, service_name)(self.db, query_object, self.user)
except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现空范围短路')
dao_method.assert_not_awaited()
self.assertEqual([], result.rows)
self.assertEqual(0, result.total)
self.assertEqual(query_object.page_num, result.page_num)
self.assertEqual(query_object.page_size, result.page_size)
self.assertFalse(result.has_next)
async def test_inventory_return_and_trace_only_short_circuit_when_both_scopes_empty(self) -> None:
empty_cases = (
(
'get_pallet_inventory_list_services',
'get_pallet_inventory_list',
PalletInventoryReportQueryModel(pageNum=3, pageSize=4),
),
(
'get_return_report_list_services',
'get_return_report_list',
ReturnReportQueryModel(pageNum=3, pageSize=4),
),
(
'get_pallet_trace_list_services',
'get_pallet_trace_list',
PalletTraceReportQueryModel(pageNum=3, pageSize=4),
),
)
for service_name, dao_name, query_object in empty_cases:
with self.subTest(service_name=service_name):
await self._assert_empty_page_without_dao(
service_name,
dao_name,
query_object,
[],
[],
)
visible_cases = (
(
'get_pallet_inventory_list_services',
'get_pallet_inventory_list',
PalletInventoryReportQueryModel(),
),
('get_return_report_list_services', 'get_return_report_list', ReturnReportQueryModel()),
(
'get_pallet_trace_list_services',
'get_pallet_trace_list',
PalletTraceReportQueryModel(),
),
)
for service_name, dao_name, query_object in visible_cases:
with self.subTest(service_name=service_name, one_scope_visible=True):
dao_method = AsyncMock(return_value=PageModel(rows=[], pageNum=1, pageSize=10, total=0, hasNext=False))
with (
patch.object(ReportDao, dao_name, new=dao_method),
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[])),
):
try:
await getattr(ReportService, service_name)(self.db, query_object, self.user)
except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现单侧 OR 范围可见性')
dao_method.assert_awaited_once()
async def test_filling_and_consumption_empty_applicable_scope_short_circuits(self) -> None:
await self._assert_empty_page_without_dao(
'get_filling_report_list_services',
'get_filling_report_list',
FillingReportQueryModel(pageNum=3, pageSize=4),
[],
[9],
)
await self._assert_empty_page_without_dao(
'get_consumption_report_list_services',
'get_consumption_report_list',
ConsumptionReportQueryModel(pageNum=3, pageSize=4),
[7],
[],
)
async def test_shipment_either_empty_scope_short_circuits(self) -> None:
for warehouse_ids, site_ids in (([], [9]), ([7], []), ([], [])):
with self.subTest(warehouse_ids=warehouse_ids, site_ids=site_ids):
await self._assert_empty_page_without_dao(
'get_shipment_report_list_services',
'get_shipment_report_list',
ShipmentReportQueryModel(pageNum=3, pageSize=4),
warehouse_ids,
site_ids,
)
@unittest.skipUnless(report_controller is not None, 'report_controller 尚未实现')
class TestReportControllerContract(unittest.TestCase):
@staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]:
return {
(route.path, next(iter(route.methods))): route
for route in report_controller.routes
if isinstance(route, APIRoute)
}
def test_create_app_openapi_has_exact_six_report_get_operations_and_aliases(self) -> None:
schema = create_app().openapi()
report_operations = {
(path, method)
for path, methods in schema['paths'].items()
if path.startswith('/report/')
for method in methods
}
expected_operations = {
('/report/pallet-inventory/list', 'get'),
('/report/filling/list', 'get'),
('/report/shipment/list', 'get'),
('/report/consumption/list', 'get'),
('/report/return/list', 'get'),
('/report/pallet-trace/list', 'get'),
}
self.assertEqual(expected_operations, report_operations)
expected_parameters = {
'/report/pallet-inventory/list': {
'pageNum',
'pageSize',
'beginTime',
'endTime',
'palletCode',
'flowState',
'warehouseId',
'siteId',
'lineId',
'strainId',
'batchNo',
},
'/report/filling/list': {
'pageNum',
'pageSize',
'beginTime',
'endTime',
'warehouseId',
'palletCode',
'strainId',
'batchNo',
},
'/report/shipment/list': {
'pageNum',
'pageSize',
'beginTime',
'endTime',
'shipmentNo',
'sourceWarehouseId',
'targetSiteId',
'palletCode',
'status',
},
'/report/consumption/list': {
'pageNum',
'pageSize',
'beginTime',
'endTime',
'customerId',
'siteId',
'lineId',
'palletCode',
'strainId',
'batchNo',
},
'/report/return/list': {
'pageNum',
'pageSize',
'beginTime',
'endTime',
'returnNo',
'sourceSiteId',
'targetWarehouseId',
'palletCode',
'status',
},
'/report/pallet-trace/list': {
'pageNum',
'pageSize',
'beginTime',
'endTime',
'palletCode',
'eventType',
},
}
for path, parameters in expected_parameters.items():
with self.subTest(path=path):
actual = {parameter['name'] for parameter in schema['paths'][path]['get']['parameters']}
self.assertEqual(parameters, actual)
self.assertNotIn('mode', actual)
def test_routes_have_exact_permissions_response_models_current_user_and_no_log(self) -> None:
expected = {
('/report/pallet-inventory/list', 'GET'): (
'report:palletinventory:list',
PageResponseModel[PalletInventoryReportModel],
'get_pallet_inventory_list_services',
),
('/report/filling/list', 'GET'): (
'report:filling:list',
PageResponseModel[FillingReportModel],
'get_filling_report_list_services',
),
('/report/shipment/list', 'GET'): (
'report:shipment:list',
PageResponseModel[ShipmentReportModel],
'get_shipment_report_list_services',
),
('/report/consumption/list', 'GET'): (
'report:consumption:list',
PageResponseModel[ConsumptionReportModel],
'get_consumption_report_list_services',
),
('/report/return/list', 'GET'): (
'report:return:list',
PageResponseModel[ReturnReportModel],
'get_return_report_list_services',
),
('/report/pallet-trace/list', 'GET'): (
'report:pallettrace:list',
PageResponseModel[PalletTraceReportModel],
'get_pallet_trace_list_services',
),
}
routes = self._routes()
self.assertEqual(set(expected), set(routes))
for key, (permission, response_model, service_method) in expected.items():
route = routes[key]
permissions = [
dependency.dependency
for dependency in route.dependencies
if isinstance(dependency.dependency, CheckUserInterfaceAuth)
]
self.assertEqual(1, len(permissions), key)
self.assertEqual(permission, permissions[0].perm)
self.assertEqual(response_model, route.response_model)
self.assertTrue(any(isinstance(dependency.dependency, PreAuth) for dependency in route.dependencies))
self.assertTrue(
any(
dependency.name == 'current_user' and dependency.call == LoginService.get_current_user
for dependency in route.dependant.dependencies
),
key,
)
self.assertFalse(hasattr(route.endpoint, '__wrapped__'), key)
self.assertIn(service_method, inspect.getsource(route.endpoint))
def test_controller_source_has_no_export_write_route_or_log(self) -> None:
source = inspect.getsource(report_controller_module)
self.assertNotIn('@Log(', source)
self.assertNotIn('/export', source)
for decorator in ('.post(', '.put(', '.delete(', '.patch('):
self.assertNotIn(decorator, source)
if __name__ == '__main__':
unittest.main()