diff --git a/shierfeng-fastapi-backend/module_business/controller/customer_controller.py b/shierfeng-fastapi-backend/module_business/controller/customer_controller.py index d9b89cd..09faaec 100644 --- a/shierfeng-fastapi-backend/module_business/controller/customer_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/customer_controller.py @@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import CustomerDetailModel, CustomerListItemModel from module_business.entity.vo.master_vo import CustomerModel, CustomerPageQueryModel, DeleteCustomerModel from module_business.service.master_service import MasterService +from module_business.service.master_summary_service import MasterSummaryService from utils.response_util import ResponseUtil customer_controller = APIRouterPro( @@ -27,7 +29,7 @@ customer_controller = APIRouterPro( '/list', summary='获取客户分页列表接口', description='用于获取客户分页列表', - response_model=PageResponseModel[CustomerModel], + response_model=PageResponseModel[CustomerListItemModel], dependencies=[UserInterfaceAuthDependency('master:customer:list')], ) async def get_master_customer_list( @@ -37,6 +39,9 @@ async def get_master_customer_list( scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: customer_page_query_result = await MasterService.list_customer(query_db, customer_page_query, scope, is_page=True) + customer_page_query_result = await MasterSummaryService.enrich_page( + query_db, 'customer', customer_page_query_result, scope + ) return ResponseUtil.success(model_content=customer_page_query_result) @@ -45,7 +50,7 @@ async def get_master_customer_list( '/{customer_id}', summary='获取客户详情接口', description='用于获取指定客户的详细信息', - response_model=DataResponseModel[CustomerModel], + response_model=DataResponseModel[CustomerDetailModel], dependencies=[UserInterfaceAuthDependency('master:customer:query')], ) async def query_detail_master_customer( @@ -55,6 +60,7 @@ async def query_detail_master_customer( scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: customer_detail_result = await MasterService.detail_customer(query_db, customer_id, scope) + customer_detail_result = await MasterSummaryService.customer_detail(query_db, customer_detail_result, scope) return ResponseUtil.success(data=customer_detail_result) diff --git a/shierfeng-fastapi-backend/module_business/controller/filling_controller.py b/shierfeng-fastapi-backend/module_business/controller/filling_controller.py index 6e5a158..a2bb7f9 100644 --- a/shierfeng-fastapi-backend/module_business/controller/filling_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/filling_controller.py @@ -15,8 +15,14 @@ from module_business.dependency.test_batch_dependency import TestBatchScopeDepen from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.vo.operation_vo import ( BusinessOptionModel, + FillingBatchDetailModel, + FillingBatchDetailQueryModel, + FillingBatchModel, + FillingBatchPageQueryModel, FillingCreateModel, + FillingListItemModel, FillingModel, + FillingOverviewModel, FillingPageQueryModel, PalletScanResponseModel, ) @@ -34,7 +40,7 @@ filling_controller = APIRouterPro( '/list', summary='获取托盘装填记录分页列表接口', description='按当前用户仓库数据范围获取托盘装填记录分页列表', - response_model=PageResponseModel[FillingModel], + response_model=PageResponseModel[FillingListItemModel], dependencies=[UserInterfaceAuthDependency('business:filling:list')], ) async def get_business_filling_list( @@ -54,6 +60,64 @@ async def get_business_filling_list( return ResponseUtil.success(model_content=result) +@filling_controller.get( + '/summary', + summary='获取工厂装填概况接口', + description='按当前用户仓库数据范围统计今日与本月装填、可装填空托盘和临期仍在仓的托盘', + response_model=DataResponseModel[FillingOverviewModel], + dependencies=[UserInterfaceAuthDependency('business:filling:list')], +) +async def get_business_filling_summary( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + result = await FillingService.get_filling_overview_services(query_db, current_user, test_scope) + return ResponseUtil.success(data=result) + + +@filling_controller.get( + '/batches', + summary='获取生产批次装填汇总分页列表接口', + description='按当前用户仓库数据范围,将装填记录按菌种与生产批次聚合,最近装填的批次在前', + response_model=PageResponseModel[FillingBatchModel], + dependencies=[UserInterfaceAuthDependency('business:filling:list')], +) +async def get_business_filling_batches( + request: Request, + batch_page_query: Annotated[FillingBatchPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + result = await FillingService.get_filling_batches_services(query_db, batch_page_query, current_user, test_scope) + return ResponseUtil.success(model_content=result) + + +@filling_controller.get( + '/batches/detail', + summary='获取生产批次详情接口', + description='按当前用户仓库数据范围返回批次汇总、托盘当前去向与最近装填记录', + response_model=DataResponseModel[FillingBatchDetailModel], + dependencies=[UserInterfaceAuthDependency('business:filling:query')], +) +async def get_business_filling_batch_detail( + request: Request, + batch_detail_query: Annotated[FillingBatchDetailQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + result = await FillingService.get_filling_batch_detail_services( + query_db, + batch_detail_query, + current_user, + test_scope, + ) + return ResponseUtil.success(data=result) + + @filling_controller.get( '/strain-options', summary='获取装填菌种选项接口', diff --git a/shierfeng-fastapi-backend/module_business/controller/inbound_controller.py b/shierfeng-fastapi-backend/module_business/controller/inbound_controller.py index 3101a4f..7dd5c89 100644 --- a/shierfeng-fastapi-backend/module_business/controller/inbound_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/inbound_controller.py @@ -17,7 +17,9 @@ from module_business.entity.vo.operation_vo import ( BusinessOptionModel, ConfirmCommandModel, InboundCreateModel, + InboundListItemModel, InboundModel, + InboundOverviewModel, InboundPageQueryModel, InboundScanQueryModel, InboundUpdateModel, @@ -37,7 +39,7 @@ inbound_controller = APIRouterPro( '/list', summary='获取首次入库单分页列表接口', description='按当前用户仓库数据范围获取首次入库单分页列表', - response_model=PageResponseModel[InboundModel], + response_model=PageResponseModel[InboundListItemModel], dependencies=[UserInterfaceAuthDependency('business:inbound:list')], ) async def get_business_inbound_list( @@ -57,6 +59,23 @@ async def get_business_inbound_list( return ResponseUtil.success(model_content=result) +@inbound_controller.get( + '/summary', + summary='获取首次入库概况接口', + description='按当前用户仓库数据范围统计待确认草稿、本月入库、待首次入库托盘与仓库空托盘', + response_model=DataResponseModel[InboundOverviewModel], + dependencies=[UserInterfaceAuthDependency('business:inbound:list')], +) +async def get_business_inbound_summary( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + result = await InboundService.get_inbound_overview_services(query_db, current_user, test_scope) + return ResponseUtil.success(data=result) + + @inbound_controller.get( '/warehouse-options', summary='获取首次入库仓库选项接口', diff --git a/shierfeng-fastapi-backend/module_business/controller/line_controller.py b/shierfeng-fastapi-backend/module_business/controller/line_controller.py index 9c24d3f..116a0c0 100644 --- a/shierfeng-fastapi-backend/module_business/controller/line_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/line_controller.py @@ -14,18 +14,40 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import LineListItemModel, MasterParentOptionModel from module_business.entity.vo.master_vo import DeleteLineModel, LineModel, LinePageQueryModel from module_business.service.master_service import MasterService +from module_business.service.master_summary_service import MasterSummaryService from utils.response_util import ResponseUtil line_controller = APIRouterPro(prefix='/master/line', tags=['基础资料-生产线管理'], dependencies=[PreAuthDependency()]) +# 上级资料选项单独注册且排在详情路由之前,避免被 /{line_id} 捕获 +line_option_controller = APIRouterPro( + prefix='/master/line', order_num=99, tags=['基础资料-生产线管理'], dependencies=[PreAuthDependency()] +) + + +@line_option_controller.get( + '/site-options', + summary='获取产线可选现场接口', + description='新增或修改产线时选择所属现场,只返回状态正常的现场', + response_model=DataResponseModel[list[MasterParentOptionModel]], + dependencies=[UserInterfaceAuthDependency('master:line:list')], +) +async def get_master_line_site_options( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + options = await MasterSummaryService.site_options(query_db, scope) + return ResponseUtil.success(data=options) @line_controller.get( '/list', summary='获取生产线分页列表接口', description='用于获取生产线分页列表', - response_model=PageResponseModel[LineModel], + response_model=PageResponseModel[LineListItemModel], dependencies=[UserInterfaceAuthDependency('master:line:list')], ) async def get_master_line_list( @@ -35,6 +57,7 @@ async def get_master_line_list( scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: line_page_query_result = await MasterService.list_line(query_db, line_page_query, scope, is_page=True) + line_page_query_result = await MasterSummaryService.enrich_page(query_db, 'line', line_page_query_result, scope) return ResponseUtil.success(model_content=line_page_query_result) diff --git a/shierfeng-fastapi-backend/module_business/controller/master_excel_controller.py b/shierfeng-fastapi-backend/module_business/controller/master_excel_controller.py index d2e5065..e475e69 100644 --- a/shierfeng-fastapi-backend/module_business/controller/master_excel_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/master_excel_controller.py @@ -88,7 +88,14 @@ async def _import_excel( db: AsyncSession, current_user: CurrentUserModel, scope: TestBatchScope, + preview: bool = False, ) -> Response: + if preview: + preview_result = await MasterExcelService.preview_import( + db, resource, file, update_support, current_user, scope + ) + logger.info('导入校验完成') + return ResponseUtil.success(msg='校验完成,尚未写入', data=preview_result) result = await MasterExcelService.import_resource( db, resource, file, update_support, current_user, scope ) @@ -127,8 +134,9 @@ async def import_master_strain_excel( query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], + preview: Annotated[bool, Query(description='只校验不写入')] = False, ) -> Response: - return await _import_excel('strain', file, update_support, query_db, current_user, scope) + return await _import_excel('strain', file, update_support, query_db, current_user, scope, preview) @strain_excel_controller.post( @@ -176,8 +184,9 @@ async def import_master_customer_excel( query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], + preview: Annotated[bool, Query(description='只校验不写入')] = False, ) -> Response: - return await _import_excel('customer', file, update_support, query_db, current_user, scope) + return await _import_excel('customer', file, update_support, query_db, current_user, scope, preview) @customer_excel_controller.post( @@ -225,8 +234,9 @@ async def import_master_site_excel( query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], + preview: Annotated[bool, Query(description='只校验不写入')] = False, ) -> Response: - return await _import_excel('site', file, update_support, query_db, current_user, scope) + return await _import_excel('site', file, update_support, query_db, current_user, scope, preview) @site_excel_controller.post( @@ -274,8 +284,9 @@ async def import_master_line_excel( query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], + preview: Annotated[bool, Query(description='只校验不写入')] = False, ) -> Response: - return await _import_excel('line', file, update_support, query_db, current_user, scope) + return await _import_excel('line', file, update_support, query_db, current_user, scope, preview) @line_excel_controller.post( @@ -323,8 +334,9 @@ async def import_master_warehouse_excel( query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], + preview: Annotated[bool, Query(description='只校验不写入')] = False, ) -> Response: - return await _import_excel('warehouse', file, update_support, query_db, current_user, scope) + return await _import_excel('warehouse', file, update_support, query_db, current_user, scope, preview) @warehouse_excel_controller.post( @@ -372,8 +384,9 @@ async def import_master_pallet_excel( query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], + preview: Annotated[bool, Query(description='只校验不写入')] = False, ) -> Response: - return await _import_excel('pallet', file, update_support, query_db, current_user, scope) + return await _import_excel('pallet', file, update_support, query_db, current_user, scope, preview) @pallet_excel_controller.post( @@ -421,8 +434,9 @@ async def import_master_tunnel_excel( query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], + preview: Annotated[bool, Query(description='只校验不写入')] = False, ) -> Response: - return await _import_excel('tunnel', file, update_support, query_db, current_user, scope) + return await _import_excel('tunnel', file, update_support, query_db, current_user, scope, preview) @tunnel_excel_controller.post( diff --git a/shierfeng-fastapi-backend/module_business/controller/pallet_controller.py b/shierfeng-fastapi-backend/module_business/controller/pallet_controller.py index 3ae4f22..3814d3b 100644 --- a/shierfeng-fastapi-backend/module_business/controller/pallet_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/pallet_controller.py @@ -13,13 +13,14 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import PalletDetailModel, PalletListItemModel from module_business.entity.vo.master_vo import ( DeletePalletModel, PalletCreateModel, PalletEditModel, - PalletModel, PalletPageQueryModel, ) +from module_business.service.master_summary_service import MasterSummaryService from module_business.service.pallet_service import PalletService from utils.response_util import ResponseUtil @@ -32,7 +33,7 @@ pallet_controller = APIRouterPro( '/list', summary='获取托盘分页列表接口', description='用于获取托盘分页列表', - response_model=PageResponseModel[PalletModel], + response_model=PageResponseModel[PalletListItemModel], dependencies=[UserInterfaceAuthDependency('master:pallet:list')], ) async def get_master_pallet_list( @@ -42,6 +43,7 @@ async def get_master_pallet_list( scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await PalletService.list_pallet(query_db, pallet_page_query, scope, is_page=True) + result = await MasterSummaryService.enrich_page(query_db, 'pallet', result, scope) return ResponseUtil.success(model_content=result) @@ -67,7 +69,7 @@ async def query_master_pallet_qrcode( '/{pallet_id}', summary='获取托盘详情接口', description='用于获取指定托盘的详细信息', - response_model=DataResponseModel[PalletModel], + response_model=DataResponseModel[PalletDetailModel], dependencies=[UserInterfaceAuthDependency('master:pallet:query')], ) async def query_detail_master_pallet( @@ -77,6 +79,7 @@ async def query_detail_master_pallet( scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await PalletService.detail_pallet(query_db, pallet_id, scope) + result = await MasterSummaryService.pallet_detail(query_db, result, scope) return ResponseUtil.success(data=result) diff --git a/shierfeng-fastapi-backend/module_business/controller/return_controller.py b/shierfeng-fastapi-backend/module_business/controller/return_controller.py index fbca3bb..e7b2227 100644 --- a/shierfeng-fastapi-backend/module_business/controller/return_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/return_controller.py @@ -19,10 +19,13 @@ from module_business.entity.vo.operation_vo import ( BusinessOptionModel, PalletScanResponseModel, ReturnConfirmModel, + ReturnListItemModel, ReturnModel, + ReturnOverviewModel, ReturnPageQueryModel, ReturnRequestModel, ReturnScanQueryModel, + ReturnSiteOptionModel, ) from module_business.service.return_service import ReturnService from utils.response_util import ResponseUtil @@ -38,7 +41,7 @@ return_controller = APIRouterPro( '/list', summary='获取托盘退回单分页列表接口', description='按当前用户来源现场或目标仓库数据范围获取退回单', - response_model=PageResponseModel[ReturnModel], + response_model=PageResponseModel[ReturnListItemModel], dependencies=[UserInterfaceAuthDependency('business:return:list')], ) async def get_business_return_list( @@ -58,6 +61,40 @@ async def get_business_return_list( return ResponseUtil.success(model_content=result) +@return_controller.get( + '/summary', + summary='获取托盘退回概况接口', + description='按当前用户来源现场或目标仓库数据范围统计待核对退回、退回中托盘与本月回仓去向', + response_model=DataResponseModel[ReturnOverviewModel], + dependencies=[UserInterfaceAuthDependency('business:return:list')], +) +async def get_business_return_summary( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + result = await ReturnService.get_return_overview_services(query_db, current_user, test_scope) + return ResponseUtil.success(data=result) + + +@return_controller.get( + '/site-options', + summary='获取退回来源现场选项接口', + description='返回当前用户授权的现场及其可见退回单涉及的来源现场,用于筛选与显示名称', + response_model=DataResponseModel[list[ReturnSiteOptionModel]], + dependencies=[UserInterfaceAuthDependency('business:return:list')], +) +async def get_business_return_site_options( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + result = await ReturnService.get_site_options_services(query_db, current_user, test_scope) + return ResponseUtil.success(data=result) + + @return_controller.get( '/warehouse-options', summary='获取退回目标仓库选项接口', diff --git a/shierfeng-fastapi-backend/module_business/controller/shipment_controller.py b/shierfeng-fastapi-backend/module_business/controller/shipment_controller.py index dcbd465..4e74847 100644 --- a/shierfeng-fastapi-backend/module_business/controller/shipment_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/shipment_controller.py @@ -18,6 +18,7 @@ from module_business.entity.vo.operation_vo import ( ConfirmCommandModel, PalletScanResponseModel, ShipmentCreateModel, + ShipmentListItemModel, ShipmentModel, ShipmentPageQueryModel, ShipmentScanQueryModel, @@ -39,7 +40,7 @@ shipment_controller = APIRouterPro( '/list', summary='获取整托发货单分页列表接口', description='按当前用户仓库与现场双数据范围获取整托发货单', - response_model=PageResponseModel[ShipmentModel], + response_model=PageResponseModel[ShipmentListItemModel], dependencies=[UserInterfaceAuthDependency('business:shipment:list')], ) async def get_business_shipment_list( diff --git a/shierfeng-fastapi-backend/module_business/controller/site_controller.py b/shierfeng-fastapi-backend/module_business/controller/site_controller.py index 2f25fe1..b0a1b62 100644 --- a/shierfeng-fastapi-backend/module_business/controller/site_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/site_controller.py @@ -14,6 +14,7 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import MasterParentOptionModel, SiteListItemModel from module_business.entity.vo.master_vo import ( BusinessUserOptionModel, DeleteSiteModel, @@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import ( UserAssignModel, ) from module_business.service.master_service import MasterService +from module_business.service.master_summary_service import MasterSummaryService from module_business.service.pallet_service import PalletService from utils.response_util import ResponseUtil @@ -31,6 +33,26 @@ ASSIGN_CURRENT_USER = CurrentUserDependency site_controller = APIRouterPro( prefix='/master/site', tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()] ) +# 上级资料选项单独注册且排在详情路由之前,避免被 /{site_id} 捕获 +site_option_controller = APIRouterPro( + prefix='/master/site', order_num=99, tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()] +) + + +@site_option_controller.get( + '/customer-options', + summary='获取客户现场可选客户接口', + description='新增或修改客户现场时选择所属客户,只返回状态正常的客户', + response_model=DataResponseModel[list[MasterParentOptionModel]], + dependencies=[UserInterfaceAuthDependency('master:site:list')], +) +async def get_master_site_customer_options( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + options = await MasterSummaryService.customer_options(query_db, scope) + return ResponseUtil.success(data=options) site_user_controller = APIRouterPro( prefix='/master/site', tags=['基础资料-客户现场用户分配'], dependencies=[PreAuthDependency()] ) @@ -77,7 +99,7 @@ async def assign_master_site_users( '/list', summary='获取客户现场分页列表接口', description='用于获取客户现场分页列表', - response_model=PageResponseModel[SiteModel], + response_model=PageResponseModel[SiteListItemModel], dependencies=[UserInterfaceAuthDependency('master:site:list')], ) async def get_master_site_list( @@ -87,6 +109,7 @@ async def get_master_site_list( scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: site_page_query_result = await MasterService.list_site(query_db, site_page_query, scope, is_page=True) + site_page_query_result = await MasterSummaryService.enrich_page(query_db, 'site', site_page_query_result, scope) return ResponseUtil.success(model_content=site_page_query_result) diff --git a/shierfeng-fastapi-backend/module_business/controller/strain_controller.py b/shierfeng-fastapi-backend/module_business/controller/strain_controller.py index e63f85d..446fca7 100644 --- a/shierfeng-fastapi-backend/module_business/controller/strain_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/strain_controller.py @@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import StrainListItemModel from module_business.entity.vo.master_vo import DeleteStrainModel, StrainModel, StrainPageQueryModel from module_business.service.master_service import MasterService +from module_business.service.master_summary_service import MasterSummaryService from utils.response_util import ResponseUtil strain_controller = APIRouterPro( @@ -27,7 +29,7 @@ strain_controller = APIRouterPro( '/list', summary='获取菌种分页列表接口', description='用于获取菌种分页列表', - response_model=PageResponseModel[StrainModel], + response_model=PageResponseModel[StrainListItemModel], dependencies=[UserInterfaceAuthDependency('master:strain:list')], ) async def get_master_strain_list( @@ -37,6 +39,9 @@ async def get_master_strain_list( scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: strain_page_query_result = await MasterService.list_strain(query_db, strain_page_query, scope, is_page=True) + strain_page_query_result = await MasterSummaryService.enrich_page( + query_db, 'strain', strain_page_query_result, scope + ) return ResponseUtil.success(model_content=strain_page_query_result) diff --git a/shierfeng-fastapi-backend/module_business/controller/tunnel_controller.py b/shierfeng-fastapi-backend/module_business/controller/tunnel_controller.py index 0577c00..b730162 100644 --- a/shierfeng-fastapi-backend/module_business/controller/tunnel_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/tunnel_controller.py @@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import MasterParentOptionModel, TunnelListItemModel from module_business.entity.vo.master_vo import DeleteTunnelModel, TunnelModel, TunnelPageQueryModel from module_business.service.master_service import MasterService +from module_business.service.master_summary_service import MasterSummaryService from utils.response_util import ResponseUtil tunnel_controller = APIRouterPro( @@ -23,13 +25,33 @@ tunnel_controller = APIRouterPro( tags=['基础资料-隧道管理'], dependencies=[PreAuthDependency()], ) +# 上级资料选项单独注册且排在详情路由之前,避免被 /{tunnel_id} 捕获 +tunnel_option_controller = APIRouterPro( + prefix='/master/tunnel', order_num=99, tags=['基础资料-隧道管理'], dependencies=[PreAuthDependency()] +) + + +@tunnel_option_controller.get( + '/site-options', + summary='获取隧道可选现场接口', + description='新增或修改隧道时选择所属现场,只返回状态正常的现场', + response_model=DataResponseModel[list[MasterParentOptionModel]], + dependencies=[UserInterfaceAuthDependency('master:tunnel:list')], +) +async def get_master_tunnel_site_options( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + options = await MasterSummaryService.site_options(query_db, scope) + return ResponseUtil.success(data=options) @tunnel_controller.get( '/list', summary='获取隧道分页列表接口', description='用于获取客户现场隧道分页列表', - response_model=PageResponseModel[TunnelModel], + response_model=PageResponseModel[TunnelListItemModel], dependencies=[UserInterfaceAuthDependency('master:tunnel:list')], ) async def get_master_tunnel_list( @@ -39,6 +61,7 @@ async def get_master_tunnel_list( scope: Annotated[TestBatchScope, TestBatchScopeDependency()], ) -> Response: result = await MasterService.list_tunnel(query_db, tunnel_page_query, scope, is_page=True) + result = await MasterSummaryService.enrich_page(query_db, 'tunnel', result, scope) return ResponseUtil.success(model_content=result) diff --git a/shierfeng-fastapi-backend/module_business/controller/usage_controller.py b/shierfeng-fastapi-backend/module_business/controller/usage_controller.py index 5cdfe71..d767f8f 100644 --- a/shierfeng-fastapi-backend/module_business/controller/usage_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/usage_controller.py @@ -21,8 +21,11 @@ from module_business.entity.vo.operation_vo import ( TunnelEntryCreateModel, TunnelEntrySuccessResponseModel, UsageCreateModel, + UsageListItemModel, UsageModel, UsagePageQueryModel, + UsageSiteBoardModel, + UsageSiteOptionModel, UsageSuccessResponseModel, ) from module_business.service.usage_service import UsageService @@ -39,7 +42,7 @@ usage_controller = APIRouterPro( '/list', summary='获取客户现场使用记录分页列表接口', description='按当前用户客户现场数据范围获取使用记录', - response_model=PageResponseModel[UsageModel], + response_model=PageResponseModel[UsageListItemModel], dependencies=[UserInterfaceAuthDependency('business:usage:list')], ) async def get_business_usage_list( @@ -95,6 +98,41 @@ async def get_business_usage_tunnel_options( return ResponseUtil.success(data=result) +@usage_controller.get( + '/site-options', + summary='获取客户现场使用的现场选项接口', + description='返回当前用户有权访问且已启用的客户现场,附带启用隧道与产线数', + response_model=DataResponseModel[list[UsageSiteOptionModel]], + dependencies=[UserInterfaceAuthDependency('business:usage:list')], +) +async def get_business_usage_site_options( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + result = await UsageService.get_site_options_services(query_db, current_user, test_scope) + return ResponseUtil.success(data=result) + + +@usage_controller.get( + '/site-board', + summary='获取客户现场看板接口', + description='统计指定现场的托盘库存、今日用量、隧道使用与跳过先到托盘次数;不返回内部溯源号', + response_model=DataResponseModel[UsageSiteBoardModel], + dependencies=[UserInterfaceAuthDependency('business:usage:query')], +) +async def get_business_usage_site_board( + request: Request, + site_id: Annotated[int, Query(alias='siteId', gt=0, description='客户现场ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + result = await UsageService.get_site_board_services(query_db, site_id, current_user, test_scope) + return ResponseUtil.success(data=result) + + @usage_controller.post( '/enter-tunnel', summary='扫码入隧接口', diff --git a/shierfeng-fastapi-backend/module_business/controller/usage_fifo_controller.py b/shierfeng-fastapi-backend/module_business/controller/usage_fifo_controller.py index 7ebadcd..4917e8a 100644 --- a/shierfeng-fastapi-backend/module_business/controller/usage_fifo_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/usage_fifo_controller.py @@ -13,18 +13,22 @@ from common.vo import DataResponseModel 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.usage_fifo_vo import UsageFifoRecommendationModel +from module_business.entity.vo.usage_fifo_vo import UsageFifoQueueModel, UsageFifoRecommendationModel from module_business.service.usage_fifo_service import UsageFifoService from utils.response_util import ResponseUtil usage_fifo_controller = APIRouterPro( prefix='/business/usage-fifo', tags=['业务管理-客户现场使用'], - dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('business:usage:query')], + dependencies=[PreAuthDependency()], ) -@usage_fifo_controller.get('/recommendation', response_model=DataResponseModel[UsageFifoRecommendationModel]) +@usage_fifo_controller.get( + '/recommendation', + response_model=DataResponseModel[UsageFifoRecommendationModel], + dependencies=[UserInterfaceAuthDependency('business:usage:query')], +) async def get_usage_fifo_recommendation( request: Request, qr_token: Annotated[str, Query(alias='qrToken', min_length=1, max_length=128, pattern=r'\S')], @@ -37,3 +41,19 @@ async def get_usage_fifo_recommendation( query_db, qr_token, current_user, test_scope, tunnel_id=tunnel_id, ) return ResponseUtil.success(data=result) + + +@usage_fifo_controller.get( + '/queue', + response_model=DataResponseModel[UsageFifoQueueModel], + dependencies=[UserInterfaceAuthDependency('business:usage:query')], +) +async def get_usage_fifo_queue( + request: Request, + site_id: Annotated[int, Query(alias='siteId', gt=0, description='客户现场ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()], +) -> Response: + result = await UsageFifoService.queue(query_db, site_id, current_user, test_scope) + return ResponseUtil.success(data=result) diff --git a/shierfeng-fastapi-backend/module_business/controller/warehouse_controller.py b/shierfeng-fastapi-backend/module_business/controller/warehouse_controller.py index db62bf3..e4e5449 100644 --- a/shierfeng-fastapi-backend/module_business/controller/warehouse_controller.py +++ b/shierfeng-fastapi-backend/module_business/controller/warehouse_controller.py @@ -14,6 +14,7 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import WarehouseListItemModel from module_business.entity.vo.master_vo import ( BusinessUserOptionModel, DeleteWarehouseModel, @@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import ( WarehousePageQueryModel, ) from module_business.service.master_service import MasterService +from module_business.service.master_summary_service import MasterSummaryService from module_business.service.pallet_service import PalletService from utils.response_util import ResponseUtil @@ -77,7 +79,7 @@ async def assign_master_warehouse_users( '/list', summary='获取仓库分页列表接口', description='用于获取仓库分页列表', - response_model=PageResponseModel[WarehouseModel], + response_model=PageResponseModel[WarehouseListItemModel], dependencies=[UserInterfaceAuthDependency('master:warehouse:list')], ) async def get_master_warehouse_list( @@ -92,6 +94,9 @@ async def get_master_warehouse_list( scope, is_page=True, ) + warehouse_page_query_result = await MasterSummaryService.enrich_page( + query_db, 'warehouse', warehouse_page_query_result, scope + ) return ResponseUtil.success(model_content=warehouse_page_query_result) diff --git a/shierfeng-fastapi-backend/module_business/dao/filling_summary_dao.py b/shierfeng-fastapi-backend/module_business/dao/filling_summary_dao.py new file mode 100644 index 0000000..b89ac2d --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/dao/filling_summary_dao.py @@ -0,0 +1,381 @@ +from datetime import date, datetime +from typing import Any + +from sqlalchemy import ColumnElement, and_, false, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from module_admin.entity.do.user_do import SysUser +from module_business.dao.operation_dao import OperationDao +from module_business.domain.enums import PalletFlowState +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition +from module_business.entity.do.master_do import BizPallet, BizWarehouse +from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory +from module_business.entity.vo.operation_vo import FillingBatchPageQueryModel + +# 装填周期已结束(物料用完、托盘回到空托)的托盘在批次去向中归为一类 +CONSUMED_STATE = 'CONSUMED' +RECENT_RECORD_LIMIT = 10 +TOP_STRAIN_LIMIT = 3 + + +class FillingSummaryDao: + """工厂装填的只读汇总:列表名称、按生产批次聚合、批次去向与页面概况。 + + 装填记录按仓库数据范围筛选;所有关联表均受同一测试批次范围约束。 + """ + + @staticmethod + def _batch_columns() -> list[Any]: + return [ + BizFillingRecord.strain_id, + BizFillingRecord.batch_no, + func.max(BizFillingRecord.strain_code_snapshot).label('strain_code'), + func.max(BizFillingRecord.strain_name_snapshot).label('strain_name'), + func.min(BizFillingRecord.production_date).label('production_date'), + func.min(BizFillingRecord.expiry_date).label('expiry_date'), + func.count(BizFillingRecord.filling_id).label('pallet_count'), + func.sum(BizFillingRecord.initial_packages).label('package_sum'), + func.max(BizFillingRecord.operated_at).label('last_operated_at'), + func.max(BizFillingRecord.filling_id).label('last_filling_id'), + ] + + @staticmethod + def _batch_row(row: Any) -> dict[str, Any]: + return { + 'strain_id': row.strain_id, + 'strain_code': row.strain_code, + 'strain_name': row.strain_name, + 'batch_no': row.batch_no, + 'production_date': row.production_date, + 'expiry_date': row.expiry_date, + 'package_spec': None, + 'pallet_count': int(row.pallet_count or 0), + 'package_sum': int(row.package_sum or 0), + 'last_operated_at': row.last_operated_at, + 'last_operator_name': None, + 'last_filling_id': row.last_filling_id, + } + + @staticmethod + async def _attach_latest_record( + db: AsyncSession, + batches: list[dict[str, Any]], + scope: TestBatchScope, + ) -> None: + """用批次内最新一条记录补充包装规格与操作人。""" + latest_ids = sorted({batch['last_filling_id'] for batch in batches if batch['last_filling_id']}) + if not latest_ids: + return + statement = ( + select(BizFillingRecord.filling_id, BizFillingRecord.package_spec, SysUser.nick_name) + .select_from(BizFillingRecord) + .outerjoin(SysUser, and_(SysUser.user_id == BizFillingRecord.operator_id, SysUser.del_flag == '0')) + .where( + BizFillingRecord.filling_id.in_(latest_ids), + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + ) + ) + latest = {filling_id: (spec, name) for filling_id, spec, name in (await db.execute(statement)).all()} + for batch in batches: + spec, name = latest.get(batch['last_filling_id'], (None, None)) + batch['package_spec'] = spec + batch['last_operator_name'] = name + + @staticmethod + async def get_list_summaries( + db: AsyncSession, + filling_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = sorted(set(filling_ids)) + if not ids: + return {} + statement = ( + select( + BizFillingRecord.filling_id, + BizPallet.pallet_code, + BizWarehouse.warehouse_name, + SysUser.nick_name, + ) + .select_from(BizFillingRecord) + .outerjoin( + BizPallet, + and_( + BizPallet.pallet_id == BizFillingRecord.pallet_id, + batch_scope_condition(BizPallet.test_batch_id, scope), + ), + ) + .outerjoin( + BizWarehouse, + and_( + BizWarehouse.warehouse_id == BizFillingRecord.warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), + ), + ) + .outerjoin(SysUser, and_(SysUser.user_id == BizFillingRecord.operator_id, SysUser.del_flag == '0')) + .where( + BizFillingRecord.filling_id.in_(ids), + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + ) + ) + summaries: dict[int, dict[str, Any]] = { + filling_id: {'pallet_code': None, 'warehouse_name': None, 'operator_name': None} for filling_id in ids + } + for filling_id, pallet_code, warehouse_name, operator_name in (await db.execute(statement)).all(): + summary = summaries.get(filling_id) + if summary is not None: + summary.update(pallet_code=pallet_code, warehouse_name=warehouse_name, operator_name=operator_name) + return summaries + + @classmethod + async def get_batch_page( + cls, + db: AsyncSession, + query_object: FillingBatchPageQueryModel, + warehouse_ids: list[int] | None, + scope: TestBatchScope, + ) -> tuple[list[dict[str, Any]], int]: + """按菌种 + 生产批次聚合,最近装填的批次在前。""" + conditions: list[ColumnElement[bool]] = OperationDao._filling_scope_conditions(warehouse_ids, scope) + if query_object.keyword: + keyword = f'%{query_object.keyword}%' + conditions.append( + or_( + BizFillingRecord.batch_no.like(keyword), + BizFillingRecord.strain_code_snapshot.like(keyword), + BizFillingRecord.strain_name_snapshot.like(keyword), + ) + ) + if query_object.strain_id: + conditions.append(BizFillingRecord.strain_id == query_object.strain_id) + conditions.extend( + OperationDao._time_conditions( + BizFillingRecord.operated_at, + query_object.begin_time, + query_object.end_time, + ) + ) + grouped = ( + select(*cls._batch_columns()) + .where(*conditions) + .group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no) + ) + total = (await db.execute(select(func.count()).select_from(grouped.subquery()))).scalar_one() + page_statement = ( + grouped.order_by( + func.max(BizFillingRecord.operated_at).desc(), + func.max(BizFillingRecord.filling_id).desc(), + ) + .offset((query_object.page_num - 1) * query_object.page_size) + .limit(query_object.page_size) + ) + batches = [cls._batch_row(row) for row in (await db.execute(page_statement)).all()] + await cls._attach_latest_record(db, batches, scope) + return batches, int(total or 0) + + @classmethod + async def get_batch_detail( + cls, + db: AsyncSession, + strain_id: int, + batch_no: str, + warehouse_ids: list[int] | None, + scope: TestBatchScope, + ) -> dict[str, Any] | None: + conditions = [ + *OperationDao._filling_scope_conditions(warehouse_ids, scope), + BizFillingRecord.strain_id == strain_id, + BizFillingRecord.batch_no == batch_no, + ] + header_statement = ( + select(*cls._batch_columns()) + .where(*conditions) + .group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no) + ) + header_row = (await db.execute(header_statement)).first() + if header_row is None: + return None + batch = cls._batch_row(header_row) + + # 托盘仍在本次装填周期内时取其当前状态;周期已结束则物料已用完 + distribution_statement = ( + select(BizPalletInventory.flow_state, func.count(BizFillingRecord.filling_id)) + .select_from(BizFillingRecord) + .outerjoin( + BizPalletInventory, + and_( + BizPalletInventory.pallet_id == BizFillingRecord.pallet_id, + BizPalletInventory.active_filling_id == BizFillingRecord.filling_id, + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ), + ) + .where(*conditions) + .group_by(BizPalletInventory.flow_state) + ) + recent_statement = ( + select( + BizFillingRecord.filling_id, + BizFillingRecord.filling_no, + BizFillingRecord.pallet_id, + BizPallet.pallet_code, + BizWarehouse.warehouse_name, + SysUser.nick_name, + BizFillingRecord.operated_at, + BizFillingRecord.package_spec, + ) + .select_from(BizFillingRecord) + .outerjoin( + BizPallet, + and_( + BizPallet.pallet_id == BizFillingRecord.pallet_id, + batch_scope_condition(BizPallet.test_batch_id, scope), + ), + ) + .outerjoin( + BizWarehouse, + and_( + BizWarehouse.warehouse_id == BizFillingRecord.warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), + ), + ) + .outerjoin(SysUser, and_(SysUser.user_id == BizFillingRecord.operator_id, SysUser.del_flag == '0')) + .where(*conditions) + .order_by(BizFillingRecord.operated_at.desc(), BizFillingRecord.filling_id.desc()) + .limit(RECENT_RECORD_LIMIT) + ) + + counts: dict[str, int] = {} + for flow_state, count in (await db.execute(distribution_statement)).all(): + state = flow_state or CONSUMED_STATE + counts[state] = counts.get(state, 0) + int(count or 0) + recent_rows = (await db.execute(recent_statement)).all() + batch['distribution'] = [ + {'state': state, 'pallet_count': count} + for state, count in sorted(counts.items(), key=lambda entry: (-entry[1], entry[0])) + ] + batch['recent'] = [ + { + 'filling_id': filling_id, + 'filling_no': filling_no, + 'pallet_id': pallet_id, + 'pallet_code': pallet_code, + 'warehouse_name': warehouse_name, + 'operator_name': operator_name, + 'operated_at': operated_at, + } + for filling_id, filling_no, pallet_id, pallet_code, warehouse_name, operator_name, operated_at, _ in recent_rows + ] + if recent_rows: + batch['package_spec'] = recent_rows[0][7] + batch['last_operator_name'] = recent_rows[0][5] + return batch + + @staticmethod + async def get_overview( + db: AsyncSession, + warehouse_ids: list[int] | None, + scope: TestBatchScope, + today_start: datetime, + month_start: datetime, + near_expiry_until: date, + near_expiry_days: int, + ) -> dict[str, Any]: + """装填页概况:今日与本月装填、可装填空托盘、临期仍在仓。""" + filling_conditions = OperationDao._filling_scope_conditions(warehouse_ids, scope) + inventory_conditions = [batch_scope_condition(BizPalletInventory.test_batch_id, scope)] + if warehouse_ids is not None: + scoped_ids = sorted(set(warehouse_ids)) + inventory_conditions.append(BizPalletInventory.warehouse_id.in_(scoped_ids) if scoped_ids else false()) + + def batches_since(since: datetime) -> Any: + return ( + select( + BizFillingRecord.strain_id, + BizFillingRecord.batch_no, + func.max(BizFillingRecord.strain_name_snapshot), + func.max(BizFillingRecord.strain_code_snapshot), + func.count(BizFillingRecord.filling_id), + func.sum(BizFillingRecord.initial_packages), + ) + .where(*filling_conditions, BizFillingRecord.operated_at >= since) + .group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no) + ) + + empty_statement = ( + select( + BizPalletInventory.warehouse_id, + BizWarehouse.warehouse_name, + func.count(BizPalletInventory.pallet_id), + ) + .select_from(BizPalletInventory) + .outerjoin( + BizWarehouse, + and_( + BizWarehouse.warehouse_id == BizPalletInventory.warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), + ), + ) + .where( + *inventory_conditions, + BizPalletInventory.flow_state == PalletFlowState.EMPTY_IN_WAREHOUSE.value, + BizPalletInventory.warehouse_id.is_not(None), + ) + .group_by(BizPalletInventory.warehouse_id, BizWarehouse.warehouse_name) + ) + near_expiry_statement = ( + select( + BizFillingRecord.batch_no, + func.min(BizFillingRecord.expiry_date), + func.count(BizPalletInventory.pallet_id), + ) + .select_from(BizPalletInventory) + .join( + BizFillingRecord, + and_( + BizFillingRecord.filling_id == BizPalletInventory.active_filling_id, + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + ), + ) + .where( + *inventory_conditions, + BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value, + BizFillingRecord.expiry_date <= near_expiry_until, + ) + .group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no) + ) + + today_rows = (await db.execute(batches_since(today_start))).all() + month_rows = (await db.execute(batches_since(month_start))).all() + empty_rows = [ + {'warehouse_id': warehouse_id, 'warehouse_name': warehouse_name, 'pallet_count': int(count or 0)} + for warehouse_id, warehouse_name, count in (await db.execute(empty_statement)).all() + ] + empty_rows.sort(key=lambda row: (-row['pallet_count'], row['warehouse_id'])) + near_rows = sorted( + (await db.execute(near_expiry_statement)).all(), + key=lambda row: (row[1] or date.max, row[0]), + ) + + strain_counts: dict[str, int] = {} + for _, _, strain_name, strain_code, count, _ in month_rows: + label = strain_name or strain_code + if label: + strain_counts[label] = strain_counts.get(label, 0) + int(count or 0) + top_strains = sorted(strain_counts.items(), key=lambda entry: (-entry[1], entry[0]))[:TOP_STRAIN_LIMIT] + nearest = near_rows[0] if near_rows else None + return { + 'today_pallet_count': sum(int(row[4] or 0) for row in today_rows), + 'today_package_sum': sum(int(row[5] or 0) for row in today_rows), + 'today_batch_count': len(today_rows), + 'month_start': month_start.date(), + 'month_pallet_count': sum(int(row[4] or 0) for row in month_rows), + 'month_batch_count': len(month_rows), + 'month_top_strains': [{'strain_name': name, 'pallet_count': count} for name, count in top_strains], + 'empty_pallet_count': sum(row['pallet_count'] for row in empty_rows), + 'empty_by_warehouse': empty_rows, + 'near_expiry_days': near_expiry_days, + 'near_expiry_pallet_count': sum(int(row[2] or 0) for row in near_rows), + 'near_expiry_batch_count': len(near_rows), + 'nearest_expiry_batch_no': nearest[0] if nearest else None, + 'nearest_expiry_date': nearest[1] if nearest else None, + } diff --git a/shierfeng-fastapi-backend/module_business/dao/inbound_summary_dao.py b/shierfeng-fastapi-backend/module_business/dao/inbound_summary_dao.py new file mode 100644 index 0000000..dfc8687 --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/dao/inbound_summary_dao.py @@ -0,0 +1,220 @@ +from datetime import datetime +from typing import Any + +from sqlalchemy import and_, false, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from module_admin.entity.do.user_do import SysUser +from module_business.domain.enums import DocumentStatus, PalletFlowState +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition +from module_business.entity.do.master_do import BizPallet, BizWarehouse +from module_business.entity.do.operation_do import BizInboundItem, BizInboundOrder, BizPalletInventory + + +class InboundSummaryDao: + """首次入库的只读汇总:列表名称与托盘数、详情托盘编号与当前状态、页面概况。 + + 列表与详情只汇总调用方已按数据范围取得的单据,所有关联表均受同一测试批次范围约束。 + """ + + @staticmethod + async def get_list_summaries( + db: AsyncSession, + inbound_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = sorted(set(inbound_ids)) + if not ids: + return {} + + creator = aliased(SysUser, name='creator') + confirmer = aliased(SysUser, name='confirmer') + name_statement = ( + select( + BizInboundOrder.inbound_id, + BizWarehouse.warehouse_name, + creator.nick_name, + confirmer.nick_name, + ) + .select_from(BizInboundOrder) + .outerjoin( + BizWarehouse, + and_( + BizWarehouse.warehouse_id == BizInboundOrder.warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), + ), + ) + .outerjoin(creator, and_(creator.user_name == BizInboundOrder.create_by, creator.del_flag == '0')) + .outerjoin(confirmer, and_(confirmer.user_id == BizInboundOrder.confirmed_by, confirmer.del_flag == '0')) + .where( + BizInboundOrder.inbound_id.in_(ids), + batch_scope_condition(BizInboundOrder.test_batch_id, scope), + ) + ) + count_statement = ( + select(BizInboundItem.inbound_id, func.count(BizInboundItem.inbound_item_id)) + .where( + BizInboundItem.inbound_id.in_(ids), + batch_scope_condition(BizInboundItem.test_batch_id, scope), + ) + .group_by(BizInboundItem.inbound_id) + ) + + summaries: dict[int, dict[str, Any]] = { + inbound_id: { + 'warehouse_name': None, + 'creator_name': None, + 'confirmer_name': None, + 'pallet_count': 0, + } + for inbound_id in ids + } + for inbound_id, warehouse_name, creator_name, confirmer_name in (await db.execute(name_statement)).all(): + summary = summaries.get(inbound_id) + if summary is None: + continue + summary.update( + warehouse_name=warehouse_name, + creator_name=creator_name, + confirmer_name=confirmer_name, + ) + for inbound_id, pallet_count in (await db.execute(count_statement)).all(): + summary = summaries.get(inbound_id) + if summary is not None: + summary['pallet_count'] = int(pallet_count or 0) + return summaries + + @staticmethod + async def get_item_identities( + db: AsyncSession, + inbound_id: int, + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + """为详情明细补充托盘编号与当前流转状态,只读展示,不参与任何写入。""" + statement = ( + select(BizInboundItem.inbound_item_id, BizPallet.pallet_code, BizPalletInventory.flow_state) + .select_from(BizInboundItem) + .outerjoin( + BizPallet, + and_( + BizPallet.pallet_id == BizInboundItem.pallet_id, + batch_scope_condition(BizPallet.test_batch_id, scope), + ), + ) + .outerjoin( + BizPalletInventory, + and_( + BizPalletInventory.pallet_id == BizInboundItem.pallet_id, + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ), + ) + .where( + BizInboundItem.inbound_id == inbound_id, + batch_scope_condition(BizInboundItem.test_batch_id, scope), + ) + ) + return { + inbound_item_id: {'pallet_code': pallet_code, 'flow_state': flow_state} + for inbound_item_id, pallet_code, flow_state in (await db.execute(statement)).all() + } + + @staticmethod + async def get_overview( + db: AsyncSession, + warehouse_ids: list[int] | None, + scope: TestBatchScope, + month_start: datetime, + ) -> dict[str, Any]: + """入库页概况。单据与空托盘按仓库数据范围统计;未入库托盘尚无仓库归属,按测试批次范围统计。""" + order_conditions = [batch_scope_condition(BizInboundOrder.test_batch_id, scope)] + inventory_conditions = [batch_scope_condition(BizPalletInventory.test_batch_id, scope)] + if warehouse_ids is not None: + scoped_ids = sorted(set(warehouse_ids)) + order_conditions.append(BizInboundOrder.warehouse_id.in_(scoped_ids) if scoped_ids else false()) + inventory_conditions.append(BizPalletInventory.warehouse_id.in_(scoped_ids) if scoped_ids else false()) + + item_join = and_( + BizInboundItem.inbound_id == BizInboundOrder.inbound_id, + batch_scope_condition(BizInboundItem.test_batch_id, scope), + ) + draft_statement = ( + select( + func.count(func.distinct(BizInboundOrder.inbound_id)), + func.count(BizInboundItem.inbound_item_id), + func.min(BizInboundOrder.create_time), + ) + .select_from(BizInboundOrder) + .outerjoin(BizInboundItem, item_join) + .where(*order_conditions, BizInboundOrder.status == DocumentStatus.DRAFT.value) + ) + month_statement = ( + select( + func.count(func.distinct(BizInboundOrder.inbound_id)), + func.count(BizInboundItem.inbound_item_id), + ) + .select_from(BizInboundOrder) + .outerjoin(BizInboundItem, item_join) + .where( + *order_conditions, + BizInboundOrder.status == DocumentStatus.CONFIRMED.value, + BizInboundOrder.confirmed_at >= month_start, + ) + ) + unstocked_statement = ( + select(func.count(BizPalletInventory.pallet_id)) + .select_from(BizPalletInventory) + .join( + BizPallet, + and_( + BizPallet.pallet_id == BizPalletInventory.pallet_id, + batch_scope_condition(BizPallet.test_batch_id, scope), + ), + ) + .where( + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + BizPalletInventory.flow_state == PalletFlowState.UNSTOCKED.value, + BizPallet.asset_status == '0', + ) + ) + empty_statement = ( + select( + BizPalletInventory.warehouse_id, + BizWarehouse.warehouse_name, + func.count(BizPalletInventory.pallet_id), + ) + .select_from(BizPalletInventory) + .outerjoin( + BizWarehouse, + and_( + BizWarehouse.warehouse_id == BizPalletInventory.warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), + ), + ) + .where( + *inventory_conditions, + BizPalletInventory.flow_state == PalletFlowState.EMPTY_IN_WAREHOUSE.value, + BizPalletInventory.warehouse_id.is_not(None), + ) + .group_by(BizPalletInventory.warehouse_id, BizWarehouse.warehouse_name) + ) + + draft_count, draft_pallets, oldest_draft_at = (await db.execute(draft_statement)).one() + month_count, month_pallets = (await db.execute(month_statement)).one() + unstocked_count = (await db.execute(unstocked_statement)).scalar_one() + empty_rows = [ + {'warehouse_id': warehouse_id, 'warehouse_name': warehouse_name, 'pallet_count': int(count or 0)} + for warehouse_id, warehouse_name, count in (await db.execute(empty_statement)).all() + ] + empty_rows.sort(key=lambda row: (-row['pallet_count'], row['warehouse_id'])) + return { + 'draft_count': int(draft_count or 0), + 'draft_pallet_count': int(draft_pallets or 0), + 'oldest_draft_at': oldest_draft_at, + 'month_start': month_start.date(), + 'month_confirmed_count': int(month_count or 0), + 'month_confirmed_pallet_count': int(month_pallets or 0), + 'unstocked_pallet_count': int(unstocked_count or 0), + 'empty_pallet_count': sum(row['pallet_count'] for row in empty_rows), + 'empty_by_warehouse': empty_rows, + } diff --git a/shierfeng-fastapi-backend/module_business/dao/master_dao.py b/shierfeng-fastapi-backend/module_business/dao/master_dao.py index 3910fc0..236db04 100644 --- a/shierfeng-fastapi-backend/module_business/dao/master_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/master_dao.py @@ -2,12 +2,13 @@ from datetime import datetime from typing import Any from pydantic import BaseModel -from sqlalchemy import ColumnElement, Select, func, select, update +from sqlalchemy import ColumnElement, Select, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import InstrumentedAttribute from common.vo import PageModel from module_admin.entity.do.user_do import SysUser +from module_business.domain.enums import PalletFlowState from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition from module_business.entity.do.master_do import ( BizCustomer, @@ -39,6 +40,18 @@ from module_business.entity.vo.master_vo import ( ) from utils.page_util import PageUtil +_PALLET_FLOW_GROUP_STATES = { + 'unstocked': (PalletFlowState.UNSTOCKED.value,), + 'warehouse': (PalletFlowState.EMPTY_IN_WAREHOUSE.value, PalletFlowState.FILLED_IN_WAREHOUSE.value), + 'transit': (PalletFlowState.IN_TRANSIT.value,), + 'site': ( + PalletFlowState.AT_SITE_UNUSED.value, + PalletFlowState.AT_SITE_PARTIAL.value, + PalletFlowState.AT_SITE_EMPTY.value, + ), + 'returning': (PalletFlowState.RETURNING.value,), +} + class MasterDao: """菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。""" @@ -494,15 +507,44 @@ class MasterDao: ) -> PageModel | list[dict[str, Any]]: conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)] if query_object.pallet_code: - conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) + # 扫码枪输入的是二维码固定标识,按编号模糊或标识精确匹配 + conditions.append( + or_( + BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'), + BizPallet.qr_token == query_object.pallet_code, + ) + ) if query_object.asset_status: conditions.append(BizPallet.asset_status == query_object.asset_status) + if query_object.flow_group: + conditions.append(cls._pallet_flow_group_condition(query_object.flow_group, scope)) ids = cls._parse_id_list(getattr(query_object, 'ids', None)) if ids: conditions.append(BizPallet.pallet_id.in_(ids)) statement = select(BizPallet).where(*conditions).order_by(BizPallet.pallet_id) return await cls._paginate(db, statement, query_object, is_page) + @staticmethod + def _pallet_flow_group_condition(flow_group: str, scope: TestBatchScope) -> ColumnElement[bool]: + states = _PALLET_FLOW_GROUP_STATES[flow_group] + in_states = BizPallet.pallet_id.in_( + select(BizPalletInventory.pallet_id).where( + BizPalletInventory.flow_state.in_(states), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + ) + if flow_group != 'unstocked': + return in_states + # 历史托盘可能缺少库存行,同样视为未入库 + return or_( + in_states, + BizPallet.pallet_id.not_in( + select(BizPalletInventory.pallet_id).where( + batch_scope_condition(BizPalletInventory.test_batch_id, scope) + ) + ), + ) + @classmethod async def get_pallet( cls, diff --git a/shierfeng-fastapi-backend/module_business/dao/master_summary_dao.py b/shierfeng-fastapi-backend/module_business/dao/master_summary_dao.py new file mode 100644 index 0000000..aff3b9a --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/dao/master_summary_dao.py @@ -0,0 +1,997 @@ +from datetime import date, datetime, timedelta +from typing import Any + +from sqlalchemy import and_, case, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from module_admin.entity.do.user_do import SysUser +from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition +from module_business.entity.do.master_do import ( + BizCustomer, + BizCustomerSite, + BizProductionLine, + BizTunnel, + BizUserSite, + BizUserWarehouse, + BizWarehouse, +) +from module_business.entity.do.operation_do import ( + BizFillingRecord, + BizInboundOrder, + BizPalletEvent, + BizPalletInventory, + BizReturnItem, + BizReturnOrder, + BizShipmentItem, + BizShipmentOrder, + BizUsageRecord, +) + +SITE_STATES = ( + PalletFlowState.AT_SITE_UNUSED.value, + PalletFlowState.AT_SITE_PARTIAL.value, + PalletFlowState.AT_SITE_EMPTY.value, +) +FIELD_STOCK_STATES = ( + PalletFlowState.IN_TRANSIT.value, + PalletFlowState.AT_SITE_UNUSED.value, + PalletFlowState.AT_SITE_PARTIAL.value, +) +EXPIRY_WARNING_DAYS = 7 +RECENT_DAYS = 30 +USER_PREVIEW_LIMIT = 5 +RECENT_EVENT_LIMIT = 6 +PARENT_OPTION_LIMIT = 1000 + + +def _count_when(condition: Any) -> Any: + return func.coalesce(func.sum(case((condition, 1), else_=0)), 0) + + +def _sum_when(condition: Any, column: Any) -> Any: + return func.coalesce(func.sum(case((condition, column), else_=0)), 0) + + +def _active_user() -> Any: + return and_(SysUser.status == '0', SysUser.del_flag == '0') + + +class MasterSummaryDao: + """基础资料列表与详情的只读汇总。 + + 只汇总调用方已分页得到的主数据 ID;每张关联表都受同一测试批次范围约束, + 结果只用于展示,不参与任何写入校验。 + """ + + @staticmethod + def _ids(resource_ids: list[int]) -> list[int]: + return sorted(set(resource_ids)) + + @classmethod + async def get_strain_summaries( + cls, + db: AsyncSession, + strain_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = cls._ids(strain_ids) + if not ids: + return {} + in_warehouse = BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value + in_field = BizPalletInventory.flow_state.in_(FIELD_STOCK_STATES) + stock_statement = ( + select( + BizFillingRecord.strain_id, + _count_when(in_warehouse), + _sum_when(in_warehouse, BizPalletInventory.remaining_packages), + _count_when(in_field), + _sum_when(in_field, BizPalletInventory.remaining_packages), + func.min(case((in_warehouse, BizFillingRecord.expiry_date), else_=None)), + ) + .select_from(BizPalletInventory) + .join( + BizFillingRecord, + and_( + BizFillingRecord.filling_id == BizPalletInventory.active_filling_id, + BizFillingRecord.pallet_id == BizPalletInventory.pallet_id, + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + ), + ) + .where( + BizFillingRecord.strain_id.in_(ids), + BizPalletInventory.remaining_packages > 0, + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + .group_by(BizFillingRecord.strain_id) + ) + recent_from = datetime.now() - timedelta(days=RECENT_DAYS) + filling_statement = ( + select( + BizFillingRecord.strain_id, + func.max(BizFillingRecord.operated_at), + _count_when(BizFillingRecord.operated_at >= recent_from), + ) + .where( + BizFillingRecord.strain_id.in_(ids), + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + ) + .group_by(BizFillingRecord.strain_id) + ) + + summaries: dict[int, dict[str, Any]] = {strain_id: {} for strain_id in ids} + for strain_id, wh_pallets, wh_packages, field_pallets, field_packages, nearest in ( + await db.execute(stock_statement) + ).all(): + summaries[strain_id].update( + warehouse_pallet_count=int(wh_pallets), + warehouse_package_qty=int(wh_packages), + field_pallet_count=int(field_pallets), + field_package_qty=int(field_packages), + nearest_expiry_date=nearest, + ) + for strain_id, last_filled_at, recent_count in (await db.execute(filling_statement)).all(): + summaries[strain_id].update(last_filled_at=last_filled_at, filling_count_30d=int(recent_count)) + return summaries + + @classmethod + async def _site_pallet_counts( + cls, + db: AsyncSession, + group_column: Any, + condition: Any, + scope: TestBatchScope, + ) -> dict[int, dict[str, int]]: + state = BizPalletInventory.flow_state + statement = ( + select( + group_column, + _count_when(state == PalletFlowState.AT_SITE_UNUSED.value), + _count_when(state == PalletFlowState.AT_SITE_PARTIAL.value), + _count_when(state == PalletFlowState.AT_SITE_EMPTY.value), + _count_when(state == PalletFlowState.IN_TRANSIT.value), + _sum_when(state.in_(SITE_STATES), BizPalletInventory.remaining_packages), + ) + .select_from(BizPalletInventory) + .join( + BizCustomerSite, + and_( + BizCustomerSite.site_id == BizPalletInventory.site_id, + batch_scope_condition(BizCustomerSite.test_batch_id, scope), + ), + ) + .where( + condition, + state.in_((*SITE_STATES, PalletFlowState.IN_TRANSIT.value)), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + .group_by(group_column) + ) + return { + key: { + 'unused_pallet_count': int(unused), + 'partial_pallet_count': int(partial), + 'empty_pallet_count': int(empty), + 'transit_pallet_count': int(transit), + 'remaining_package_qty': int(remaining), + } + for key, unused, partial, empty, transit, remaining in (await db.execute(statement)).all() + } + + @classmethod + async def _site_users( + cls, + db: AsyncSession, + site_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, list[dict[str, Any]]]: + if not site_ids: + return {} + statement = ( + select(BizUserSite.site_id, SysUser.user_id, SysUser.user_name, SysUser.nick_name) + .select_from(BizUserSite) + .join( + BizCustomerSite, + and_( + BizCustomerSite.site_id == BizUserSite.site_id, + batch_scope_condition(BizCustomerSite.test_batch_id, scope), + ), + ) + .join(SysUser, and_(SysUser.user_id == BizUserSite.user_id, _active_user())) + .where(BizUserSite.site_id.in_(site_ids)) + .order_by(BizUserSite.site_id, SysUser.user_id) + ) + users: dict[int, list[dict[str, Any]]] = {} + for site_id, user_id, user_name, nick_name in (await db.execute(statement)).all(): + users.setdefault(site_id, []).append({'user_id': user_id, 'user_name': user_name, 'nick_name': nick_name}) + return users + + @classmethod + async def _count_by( + cls, + db: AsyncSession, + group_column: Any, + count_column: Any, + *conditions: Any, + join: tuple[Any, Any] | None = None, + ) -> dict[int, int]: + statement = select(group_column, func.count(count_column)) + if join is not None: + statement = statement.select_from(count_column.class_).join(*join) + statement = statement.where(*conditions).group_by(group_column) + return {key: int(count) for key, count in (await db.execute(statement)).all()} + + @classmethod + async def get_customer_summaries( + cls, + db: AsyncSession, + customer_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = cls._ids(customer_ids) + if not ids: + return {} + site_scope = batch_scope_condition(BizCustomerSite.test_batch_id, scope) + site_join = ( + BizCustomerSite, + and_(BizCustomerSite.site_id == BizProductionLine.site_id, site_scope), + ) + site_counts = await cls._count_by( + db, + BizCustomerSite.customer_id, + BizCustomerSite.site_id, + BizCustomerSite.customer_id.in_(ids), + site_scope, + ) + line_counts = await cls._count_by( + db, + BizCustomerSite.customer_id, + BizProductionLine.line_id, + BizCustomerSite.customer_id.in_(ids), + batch_scope_condition(BizProductionLine.test_batch_id, scope), + join=site_join, + ) + tunnel_counts = await cls._count_by( + db, + BizCustomerSite.customer_id, + BizTunnel.tunnel_id, + BizCustomerSite.customer_id.in_(ids), + batch_scope_condition(BizTunnel.test_batch_id, scope), + join=(BizCustomerSite, and_(BizCustomerSite.site_id == BizTunnel.site_id, site_scope)), + ) + pallet_counts = await cls._site_pallet_counts( + db, BizCustomerSite.customer_id, BizCustomerSite.customer_id.in_(ids), scope + ) + user_statement = ( + select(BizCustomerSite.customer_id, func.count(func.distinct(BizUserSite.user_id))) + .select_from(BizUserSite) + .join(BizCustomerSite, and_(BizCustomerSite.site_id == BizUserSite.site_id, site_scope)) + .join(SysUser, and_(SysUser.user_id == BizUserSite.user_id, _active_user())) + .where(BizCustomerSite.customer_id.in_(ids)) + .group_by(BizCustomerSite.customer_id) + ) + user_counts = {key: int(count) for key, count in (await db.execute(user_statement)).all()} + + summaries: dict[int, dict[str, Any]] = {} + for customer_id in ids: + pallets = pallet_counts.get(customer_id, {}) + summaries[customer_id] = { + 'site_count': site_counts.get(customer_id, 0), + 'line_count': line_counts.get(customer_id, 0), + 'tunnel_count': tunnel_counts.get(customer_id, 0), + 'site_pallet_count': pallets.get('unused_pallet_count', 0) + + pallets.get('partial_pallet_count', 0) + + pallets.get('empty_pallet_count', 0), + 'transit_pallet_count': pallets.get('transit_pallet_count', 0), + 'user_count': user_counts.get(customer_id, 0), + } + return summaries + + @classmethod + async def get_customer_site_tree( + cls, + db: AsyncSession, + customer_id: int, + scope: TestBatchScope, + ) -> list[dict[str, Any]]: + site_rows = ( + await db.execute( + select( + BizCustomerSite.site_id, + BizCustomerSite.site_code, + BizCustomerSite.site_name, + BizCustomerSite.address, + BizCustomerSite.status, + ) + .where( + BizCustomerSite.customer_id == customer_id, + batch_scope_condition(BizCustomerSite.test_batch_id, scope), + ) + .order_by(BizCustomerSite.site_code, BizCustomerSite.site_id) + ) + ).all() + sites = { + row.site_id: { + 'site_id': row.site_id, + 'site_code': row.site_code, + 'site_name': row.site_name, + 'address': row.address, + 'status': row.status, + 'lines': [], + 'tunnels': [], + 'users': [], + } + for row in site_rows + } + if not sites: + return [] + site_ids = list(sites) + + line_pallets = ( + select( + BizPalletInventory.current_line_id, + func.count(BizPalletInventory.pallet_id).label('pallet_count'), + ) + .where( + BizPalletInventory.site_id.in_(site_ids), + BizPalletInventory.current_line_id.is_not(None), + BizPalletInventory.flow_state.in_(SITE_STATES), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + .group_by(BizPalletInventory.current_line_id) + .subquery() + ) + line_rows = ( + await db.execute( + select( + BizProductionLine.site_id, + BizProductionLine.line_id, + BizProductionLine.line_code, + BizProductionLine.line_name, + BizProductionLine.status, + func.coalesce(line_pallets.c.pallet_count, 0), + ) + .outerjoin(line_pallets, line_pallets.c.current_line_id == BizProductionLine.line_id) + .where( + BizProductionLine.site_id.in_(site_ids), + batch_scope_condition(BizProductionLine.test_batch_id, scope), + ) + .order_by(BizProductionLine.line_code, BizProductionLine.line_id) + ) + ).all() + for site_id, line_id, line_code, line_name, status, pallet_count in line_rows: + sites[site_id]['lines'].append( + { + 'line_id': line_id, + 'line_code': line_code, + 'line_name': line_name, + 'status': status, + 'active_pallet_count': int(pallet_count), + } + ) + + tunnel_pallets = ( + select( + BizPalletInventory.tunnel_id, + func.count(BizPalletInventory.pallet_id).label('pallet_count'), + ) + .where( + BizPalletInventory.site_id.in_(site_ids), + BizPalletInventory.tunnel_id.is_not(None), + BizPalletInventory.flow_state.in_(SITE_STATES), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + .group_by(BizPalletInventory.tunnel_id) + .subquery() + ) + tunnel_rows = ( + await db.execute( + select( + BizTunnel.site_id, + BizTunnel.tunnel_id, + BizTunnel.tunnel_code, + BizTunnel.tunnel_name, + BizTunnel.status, + BizTunnel.current_trace_no, + BizTunnel.current_trace_started_at, + func.coalesce(tunnel_pallets.c.pallet_count, 0), + ) + .outerjoin(tunnel_pallets, tunnel_pallets.c.tunnel_id == BizTunnel.tunnel_id) + .where( + BizTunnel.site_id.in_(site_ids), + batch_scope_condition(BizTunnel.test_batch_id, scope), + ) + .order_by(BizTunnel.tunnel_code, BizTunnel.tunnel_id) + ) + ).all() + for site_id, tunnel_id, tunnel_code, tunnel_name, status, trace_no, trace_started_at, pallet_count in ( + tunnel_rows + ): + sites[site_id]['tunnels'].append( + { + 'tunnel_id': tunnel_id, + 'tunnel_code': tunnel_code, + 'tunnel_name': tunnel_name, + 'status': status, + 'current_trace_no': trace_no, + 'current_trace_started_at': trace_started_at, + 'active_pallet_count': int(pallet_count), + } + ) + + pallet_counts = await cls._site_pallet_counts( + db, BizPalletInventory.site_id, BizPalletInventory.site_id.in_(site_ids), scope + ) + for site_id, counts in pallet_counts.items(): + sites[site_id].update(counts) + for site_id, users in (await cls._site_users(db, site_ids, scope)).items(): + sites[site_id]['users'] = users + return list(sites.values()) + + @classmethod + async def get_site_summaries( + cls, + db: AsyncSession, + site_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = cls._ids(site_ids) + if not ids: + return {} + name_rows = ( + await db.execute( + select(BizCustomerSite.site_id, BizCustomer.customer_code, BizCustomer.customer_name) + .select_from(BizCustomerSite) + .join( + BizCustomer, + and_( + BizCustomer.customer_id == BizCustomerSite.customer_id, + batch_scope_condition(BizCustomer.test_batch_id, scope), + ), + ) + .where(BizCustomerSite.site_id.in_(ids), batch_scope_condition(BizCustomerSite.test_batch_id, scope)) + ) + ).all() + line_counts = await cls._count_by( + db, + BizProductionLine.site_id, + BizProductionLine.line_id, + BizProductionLine.site_id.in_(ids), + batch_scope_condition(BizProductionLine.test_batch_id, scope), + ) + tunnel_counts = await cls._count_by( + db, + BizTunnel.site_id, + BizTunnel.tunnel_id, + BizTunnel.site_id.in_(ids), + batch_scope_condition(BizTunnel.test_batch_id, scope), + ) + pallet_counts = await cls._site_pallet_counts( + db, BizPalletInventory.site_id, BizPalletInventory.site_id.in_(ids), scope + ) + users = await cls._site_users(db, ids, scope) + + summaries: dict[int, dict[str, Any]] = { + site_id: { + 'line_count': line_counts.get(site_id, 0), + 'tunnel_count': tunnel_counts.get(site_id, 0), + **pallet_counts.get(site_id, {}), + 'user_count': len(users.get(site_id, [])), + 'users': users.get(site_id, [])[:USER_PREVIEW_LIMIT], + } + for site_id in ids + } + for site_id, customer_code, customer_name in name_rows: + summaries[site_id].update(customer_code=customer_code, customer_name=customer_name) + return summaries + + @classmethod + async def _child_site_names( + cls, + db: AsyncSession, + model: Any, + id_column: Any, + ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + statement = ( + select(id_column, BizCustomerSite.site_code, BizCustomerSite.site_name, BizCustomer.customer_name) + .select_from(model) + .join( + BizCustomerSite, + and_( + BizCustomerSite.site_id == model.site_id, + batch_scope_condition(BizCustomerSite.test_batch_id, scope), + ), + ) + .outerjoin( + BizCustomer, + and_( + BizCustomer.customer_id == BizCustomerSite.customer_id, + batch_scope_condition(BizCustomer.test_batch_id, scope), + ), + ) + .where(id_column.in_(ids), batch_scope_condition(model.test_batch_id, scope)) + ) + return { + resource_id: {'site_code': site_code, 'site_name': site_name, 'customer_name': customer_name} + for resource_id, site_code, site_name, customer_name in (await db.execute(statement)).all() + } + + @classmethod + async def get_line_summaries( + cls, + db: AsyncSession, + line_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = cls._ids(line_ids) + if not ids: + return {} + names = await cls._child_site_names(db, BizProductionLine, BizProductionLine.line_id, ids, scope) + pallet_counts = await cls._count_by( + db, + BizPalletInventory.current_line_id, + BizPalletInventory.pallet_id, + BizPalletInventory.current_line_id.in_(ids), + BizPalletInventory.flow_state.in_(SITE_STATES), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + recent_from = datetime.now() - timedelta(days=RECENT_DAYS) + recent = BizUsageRecord.occurred_at >= recent_from + usage_rows = ( + await db.execute( + select( + BizUsageRecord.line_id, + _count_when(recent), + _sum_when(recent, BizUsageRecord.used_packages), + func.max(BizUsageRecord.occurred_at), + ) + .where(BizUsageRecord.line_id.in_(ids), batch_scope_condition(BizUsageRecord.test_batch_id, scope)) + .group_by(BizUsageRecord.line_id) + ) + ).all() + summaries = { + line_id: {**names.get(line_id, {}), 'active_pallet_count': pallet_counts.get(line_id, 0)} for line_id in ids + } + for line_id, usage_count, used_packages, last_used_at in usage_rows: + summaries[line_id].update( + usage_count_30d=int(usage_count), + used_package_qty_30d=int(used_packages), + last_used_at=last_used_at, + ) + return summaries + + @classmethod + async def get_tunnel_summaries( + cls, + db: AsyncSession, + tunnel_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = cls._ids(tunnel_ids) + if not ids: + return {} + names = await cls._child_site_names(db, BizTunnel, BizTunnel.tunnel_id, ids, scope) + pallet_rows = ( + await db.execute( + select( + BizPalletInventory.tunnel_id, + func.count(BizPalletInventory.pallet_id), + func.coalesce(func.sum(BizPalletInventory.remaining_packages), 0), + ) + .where( + BizPalletInventory.tunnel_id.in_(ids), + BizPalletInventory.flow_state.in_(SITE_STATES), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + .group_by(BizPalletInventory.tunnel_id) + ) + ).all() + recent_from = datetime.now() - timedelta(days=RECENT_DAYS) + usage_rows = ( + await db.execute( + select( + BizUsageRecord.tunnel_id, + _count_when(BizUsageRecord.occurred_at >= recent_from), + func.max(BizUsageRecord.occurred_at), + ) + .where( + BizUsageRecord.tunnel_id.in_(ids), + batch_scope_condition(BizUsageRecord.test_batch_id, scope), + ) + .group_by(BizUsageRecord.tunnel_id) + ) + ).all() + summaries: dict[int, dict[str, Any]] = {tunnel_id: dict(names.get(tunnel_id, {})) for tunnel_id in ids} + for tunnel_id, pallet_count, package_qty in pallet_rows: + summaries[tunnel_id].update(active_pallet_count=int(pallet_count), active_package_qty=int(package_qty)) + for tunnel_id, usage_count, last_used_at in usage_rows: + summaries[tunnel_id].update(usage_count_30d=int(usage_count), last_used_at=last_used_at) + return summaries + + @classmethod + async def get_warehouse_summaries( + cls, + db: AsyncSession, + warehouse_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = cls._ids(warehouse_ids) + if not ids: + return {} + today_start = datetime.combine(date.today(), datetime.min.time()) + expiry_limit = date.today() + timedelta(days=EXPIRY_WARNING_DAYS) + filled = BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value + stock_rows = ( + await db.execute( + select( + BizPalletInventory.warehouse_id, + _count_when(filled), + _sum_when(filled, BizPalletInventory.remaining_packages), + _count_when(BizPalletInventory.flow_state == PalletFlowState.EMPTY_IN_WAREHOUSE.value), + _count_when(and_(filled, BizFillingRecord.expiry_date <= expiry_limit)), + ) + .select_from(BizPalletInventory) + .outerjoin( + BizFillingRecord, + and_( + BizFillingRecord.filling_id == BizPalletInventory.active_filling_id, + BizFillingRecord.pallet_id == BizPalletInventory.pallet_id, + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + ), + ) + .where( + BizPalletInventory.warehouse_id.in_(ids), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + .group_by(BizPalletInventory.warehouse_id) + ) + ).all() + filling_counts = await cls._count_by( + db, + BizFillingRecord.warehouse_id, + BizFillingRecord.filling_id, + BizFillingRecord.warehouse_id.in_(ids), + BizFillingRecord.operated_at >= today_start, + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + ) + shipment_counts = await cls._count_by( + db, + BizShipmentOrder.source_warehouse_id, + BizShipmentItem.shipment_item_id, + BizShipmentOrder.source_warehouse_id.in_(ids), + BizShipmentOrder.status == DocumentStatus.CONFIRMED.value, + BizShipmentOrder.confirmed_at >= today_start, + batch_scope_condition(BizShipmentItem.test_batch_id, scope), + join=( + BizShipmentOrder, + and_( + BizShipmentOrder.shipment_id == BizShipmentItem.shipment_id, + batch_scope_condition(BizShipmentOrder.test_batch_id, scope), + ), + ), + ) + inbound_drafts = await cls._count_by( + db, + BizInboundOrder.warehouse_id, + BizInboundOrder.inbound_id, + BizInboundOrder.warehouse_id.in_(ids), + BizInboundOrder.status == DocumentStatus.DRAFT.value, + batch_scope_condition(BizInboundOrder.test_batch_id, scope), + ) + shipment_drafts = await cls._count_by( + db, + BizShipmentOrder.source_warehouse_id, + BizShipmentOrder.shipment_id, + BizShipmentOrder.source_warehouse_id.in_(ids), + BizShipmentOrder.status == DocumentStatus.DRAFT.value, + batch_scope_condition(BizShipmentOrder.test_batch_id, scope), + ) + incoming_returns = await cls._count_by( + db, + BizReturnOrder.target_warehouse_id, + BizReturnOrder.return_id, + BizReturnOrder.target_warehouse_id.in_(ids), + BizReturnOrder.status == ReturnStatus.REQUESTED.value, + batch_scope_condition(BizReturnOrder.test_batch_id, scope), + ) + user_rows = ( + await db.execute( + select(BizUserWarehouse.warehouse_id, SysUser.user_id, SysUser.user_name, SysUser.nick_name) + .select_from(BizUserWarehouse) + .join( + BizWarehouse, + and_( + BizWarehouse.warehouse_id == BizUserWarehouse.warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), + ), + ) + .join(SysUser, and_(SysUser.user_id == BizUserWarehouse.user_id, _active_user())) + .where(BizUserWarehouse.warehouse_id.in_(ids)) + .order_by(BizUserWarehouse.warehouse_id, SysUser.user_id) + ) + ).all() + users: dict[int, list[dict[str, Any]]] = {} + for warehouse_id, user_id, user_name, nick_name in user_rows: + users.setdefault(warehouse_id, []).append( + {'user_id': user_id, 'user_name': user_name, 'nick_name': nick_name} + ) + + summaries: dict[int, dict[str, Any]] = { + warehouse_id: { + 'today_filling_count': filling_counts.get(warehouse_id, 0), + 'today_shipment_pallet_count': shipment_counts.get(warehouse_id, 0), + 'draft_count': inbound_drafts.get(warehouse_id, 0) + shipment_drafts.get(warehouse_id, 0), + 'incoming_return_count': incoming_returns.get(warehouse_id, 0), + 'user_count': len(users.get(warehouse_id, [])), + 'users': users.get(warehouse_id, [])[:USER_PREVIEW_LIMIT], + } + for warehouse_id in ids + } + for warehouse_id, filled_count, package_qty, empty_count, expiring_count in stock_rows: + summaries[warehouse_id].update( + filled_pallet_count=int(filled_count), + filled_package_qty=int(package_qty), + empty_pallet_count=int(empty_count), + expiring_pallet_count=int(expiring_count), + ) + return summaries + + @classmethod + async def get_pallet_summaries( + cls, + db: AsyncSession, + pallet_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = cls._ids(pallet_ids) + if not ids: + return {} + state_rows = ( + await db.execute( + select( + BizPalletInventory.pallet_id, + BizPalletInventory.flow_state, + BizPalletInventory.remaining_packages, + BizWarehouse.warehouse_name, + BizCustomer.customer_name, + BizCustomerSite.site_name, + BizProductionLine.line_name, + BizTunnel.tunnel_name, + BizFillingRecord.strain_name_snapshot, + BizFillingRecord.batch_no, + BizFillingRecord.expiry_date, + ) + .select_from(BizPalletInventory) + .outerjoin( + BizWarehouse, + and_( + BizWarehouse.warehouse_id == BizPalletInventory.warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), + ), + ) + .outerjoin( + BizCustomerSite, + and_( + BizCustomerSite.site_id == BizPalletInventory.site_id, + batch_scope_condition(BizCustomerSite.test_batch_id, scope), + ), + ) + .outerjoin( + BizCustomer, + and_( + BizCustomer.customer_id == BizCustomerSite.customer_id, + batch_scope_condition(BizCustomer.test_batch_id, scope), + ), + ) + .outerjoin( + BizProductionLine, + and_( + BizProductionLine.line_id == BizPalletInventory.current_line_id, + batch_scope_condition(BizProductionLine.test_batch_id, scope), + ), + ) + .outerjoin( + BizTunnel, + and_( + BizTunnel.tunnel_id == BizPalletInventory.tunnel_id, + batch_scope_condition(BizTunnel.test_batch_id, scope), + ), + ) + .outerjoin( + BizFillingRecord, + and_( + BizFillingRecord.filling_id == BizPalletInventory.active_filling_id, + BizFillingRecord.pallet_id == BizPalletInventory.pallet_id, + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + ), + ) + .where( + BizPalletInventory.pallet_id.in_(ids), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + ) + ).all() + shipped = BizPalletEvent.event_type == PalletEventType.SHIPMENT_CONFIRMED.value + event_rows = ( + await db.execute( + select( + BizPalletEvent.pallet_id, + _count_when(shipped), + func.max(case((shipped, BizPalletEvent.occurred_at), else_=None)), + func.max(BizPalletEvent.occurred_at), + ) + .where(BizPalletEvent.pallet_id.in_(ids), batch_scope_condition(BizPalletEvent.test_batch_id, scope)) + .group_by(BizPalletEvent.pallet_id) + ) + ).all() + return_rows = ( + await db.execute( + select(BizReturnItem.pallet_id, BizWarehouse.warehouse_name) + .select_from(BizReturnItem) + .join( + BizReturnOrder, + and_( + BizReturnOrder.return_id == BizReturnItem.return_id, + BizReturnOrder.status == ReturnStatus.REQUESTED.value, + batch_scope_condition(BizReturnOrder.test_batch_id, scope), + ), + ) + .join( + BizWarehouse, + and_( + BizWarehouse.warehouse_id == BizReturnOrder.target_warehouse_id, + batch_scope_condition(BizWarehouse.test_batch_id, scope), + ), + ) + .where(BizReturnItem.pallet_id.in_(ids), batch_scope_condition(BizReturnItem.test_batch_id, scope)) + ) + ).all() + + summaries: dict[int, dict[str, Any]] = {pallet_id: {} for pallet_id in ids} + for row in state_rows: + summaries[row[0]].update( + flow_state=row[1], + remaining_packages=row[2] or 0, + warehouse_name=row[3], + customer_name=row[4], + site_name=row[5], + line_name=row[6], + tunnel_name=row[7], + strain_name=row[8], + batch_no=row[9], + expiry_date=row[10], + ) + for pallet_id, cycle_count, last_shipped_at, last_event_at in event_rows: + summaries[pallet_id].update( + cycle_count=int(cycle_count), + last_shipped_at=last_shipped_at, + last_event_at=last_event_at, + ) + for pallet_id, warehouse_name in return_rows: + summaries[pallet_id]['return_warehouse_name'] = warehouse_name + return summaries + + @classmethod + async def get_pallet_recent_events( + cls, + db: AsyncSession, + pallet_id: int, + scope: TestBatchScope, + ) -> list[dict[str, Any]]: + warehouse = aliased(BizWarehouse, name='event_warehouse') + site = aliased(BizCustomerSite, name='event_site') + customer = aliased(BizCustomer, name='event_customer') + line = aliased(BizProductionLine, name='event_line') + tunnel = aliased(BizTunnel, name='event_tunnel') + operator = aliased(SysUser, name='event_operator') + statement = ( + select( + BizPalletEvent.event_id, + BizPalletEvent.event_type, + BizPalletEvent.occurred_at, + BizPalletEvent.after_state, + BizPalletEvent.before_remaining_packages, + BizPalletEvent.after_remaining_packages, + BizPalletEvent.trace_no, + warehouse.warehouse_name, + customer.customer_name, + site.site_name, + line.line_name, + tunnel.tunnel_name, + operator.nick_name, + ) + .select_from(BizPalletEvent) + .outerjoin( + warehouse, + and_( + warehouse.warehouse_id + == func.coalesce(BizPalletEvent.after_warehouse_id, BizPalletEvent.before_warehouse_id), + batch_scope_condition(warehouse.test_batch_id, scope), + ), + ) + .outerjoin( + site, + and_( + site.site_id == func.coalesce(BizPalletEvent.after_site_id, BizPalletEvent.before_site_id), + batch_scope_condition(site.test_batch_id, scope), + ), + ) + .outerjoin( + customer, + and_(customer.customer_id == site.customer_id, batch_scope_condition(customer.test_batch_id, scope)), + ) + .outerjoin( + line, + and_(line.line_id == BizPalletEvent.after_line_id, batch_scope_condition(line.test_batch_id, scope)), + ) + .outerjoin( + tunnel, + and_( + tunnel.tunnel_id == BizPalletEvent.after_tunnel_id, + batch_scope_condition(tunnel.test_batch_id, scope), + ), + ) + .outerjoin(operator, operator.user_id == BizPalletEvent.operator_id) + .where(BizPalletEvent.pallet_id == pallet_id, batch_scope_condition(BizPalletEvent.test_batch_id, scope)) + .order_by(BizPalletEvent.event_seq.desc()) + .limit(RECENT_EVENT_LIMIT) + ) + keys = ( + 'event_id', + 'event_type', + 'occurred_at', + 'after_state', + 'before_remaining_packages', + 'after_remaining_packages', + 'trace_no', + 'warehouse_name', + 'customer_name', + 'site_name', + 'line_name', + 'tunnel_name', + 'operator_name', + ) + return [dict(zip(keys, row, strict=True)) for row in (await db.execute(statement)).all()] + + @classmethod + async def list_customer_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[dict[str, Any]]: + """新增或修改现场时可选的客户:当前数据范围内状态正常的客户。""" + rows = ( + await db.execute( + select(BizCustomer.customer_id, BizCustomer.customer_code, BizCustomer.customer_name) + .where(BizCustomer.status == '0', batch_scope_condition(BizCustomer.test_batch_id, scope)) + .order_by(BizCustomer.customer_code, BizCustomer.customer_id) + .limit(PARENT_OPTION_LIMIT) + ) + ).all() + return [{'id': row[0], 'code': row[1], 'name': row[2]} for row in rows] + + @classmethod + async def list_site_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[dict[str, Any]]: + """新增或修改产线、隧道时可选的现场:状态正常且所属客户也正常的现场。""" + rows = ( + await db.execute( + select( + BizCustomerSite.site_id, + BizCustomerSite.site_code, + BizCustomerSite.site_name, + BizCustomer.customer_name, + ) + .select_from(BizCustomerSite) + .join( + BizCustomer, + and_( + BizCustomer.customer_id == BizCustomerSite.customer_id, + BizCustomer.status == '0', + batch_scope_condition(BizCustomer.test_batch_id, scope), + ), + ) + .where(BizCustomerSite.status == '0', batch_scope_condition(BizCustomerSite.test_batch_id, scope)) + .order_by(BizCustomer.customer_code, BizCustomerSite.site_code, BizCustomerSite.site_id) + .limit(PARENT_OPTION_LIMIT) + ) + ).all() + return [{'id': row[0], 'code': row[1], 'name': row[2], 'parent_name': row[3]} for row in rows] diff --git a/shierfeng-fastapi-backend/module_business/dao/operation_dao.py b/shierfeng-fastapi-backend/module_business/dao/operation_dao.py index 1c3a41b..34984f9 100644 --- a/shierfeng-fastapi-backend/module_business/dao/operation_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/operation_dao.py @@ -3,12 +3,13 @@ from typing import Any from sqlalchemy import ColumnElement, Select, and_, delete, false, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from sqlalchemy.orm.attributes import InstrumentedAttribute from common.vo import PageModel from module_business.domain.enums import DocumentStatus, PalletFlowState from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition -from module_business.entity.do.master_do import BizPallet +from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizWarehouse from module_business.entity.do.operation_do import ( BizFillingRecord, BizInboundItem, @@ -608,6 +609,15 @@ class OperationDao: conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%')) if query_object.pallet_code: conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) + if query_object.keyword: + keyword = f'%{query_object.keyword}%' + conditions.append( + or_( + BizFillingRecord.filling_no.like(keyword), + BizPallet.pallet_code.like(keyword), + BizFillingRecord.batch_no.like(keyword), + ) + ) conditions.extend( cls._time_conditions( BizFillingRecord.operated_at, @@ -616,7 +626,7 @@ class OperationDao: ) ) statement = select(BizFillingRecord) - if query_object.pallet_code: + if query_object.pallet_code or query_object.keyword: statement = statement.join( BizPallet, BizPallet.pallet_id == BizFillingRecord.pallet_id, @@ -680,6 +690,10 @@ class OperationDao: conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id) if query_object.status: conditions.append(BizShipmentOrder.status == query_object.status.value) + if query_object.receipt_state: + # 收货状态只在确认后有意义,草稿的默认值不参与筛选 + conditions.append(BizShipmentOrder.status == DocumentStatus.CONFIRMED.value) + conditions.append(BizShipmentOrder.receipt_state == query_object.receipt_state) conditions.extend( cls._time_conditions( BizShipmentOrder.create_time, @@ -687,7 +701,45 @@ class OperationDao: query_object.end_time, ) ) - statement = select(BizShipmentOrder).where(*conditions).order_by(BizShipmentOrder.shipment_id) + statement = select(BizShipmentOrder) + if query_object.keyword: + keyword = f'%{query_object.keyword}%' + source_warehouse = aliased(BizWarehouse, name='source_warehouse') + target_site = aliased(BizCustomerSite, name='target_site') + target_customer = aliased(BizCustomer, name='target_customer') + # 名称关联与单据处于同一测试批次范围,跨批次的主数据不会被匹配 + statement = ( + statement.outerjoin( + source_warehouse, + and_( + source_warehouse.warehouse_id == BizShipmentOrder.source_warehouse_id, + batch_scope_condition(source_warehouse.test_batch_id, scope), + ), + ) + .outerjoin( + target_site, + and_( + target_site.site_id == BizShipmentOrder.target_site_id, + batch_scope_condition(target_site.test_batch_id, scope), + ), + ) + .outerjoin( + target_customer, + and_( + target_customer.customer_id == target_site.customer_id, + batch_scope_condition(target_customer.test_batch_id, scope), + ), + ) + ) + conditions.append( + or_( + BizShipmentOrder.shipment_no.like(keyword), + source_warehouse.warehouse_name.like(keyword), + target_site.site_name.like(keyword), + target_customer.customer_name.like(keyword), + ) + ) + statement = statement.where(*conditions).order_by(BizShipmentOrder.shipment_id) return await cls._paginate(db, statement, query_object, is_page) @classmethod @@ -818,6 +870,11 @@ class OperationDao: 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.keyword: + keyword = f'%{query_object.keyword}%' + conditions.append(or_(BizUsageRecord.usage_no.like(keyword), BizPallet.pallet_code.like(keyword))) + if query_object.fifo_skipped: + conditions.append(BizUsageRecord.fifo_skip_reason.is_not(None)) conditions.extend( cls._time_conditions( BizUsageRecord.occurred_at, @@ -826,7 +883,7 @@ class OperationDao: ) ) statement = select(BizUsageRecord) - if query_object.pallet_code: + if query_object.pallet_code or query_object.keyword: statement = statement.join( BizPallet, BizPallet.pallet_id == BizUsageRecord.pallet_id, @@ -884,7 +941,63 @@ class OperationDao: query_object.end_time, ) ) - statement = select(BizReturnOrder).where(*conditions).order_by(BizReturnOrder.return_id) + statement = select(BizReturnOrder) + if query_object.keyword: + keyword = f'%{query_object.keyword}%' + source_site = aliased(BizCustomerSite, name='source_site') + source_customer = aliased(BizCustomer, name='source_customer') + target_warehouse = aliased(BizWarehouse, name='target_warehouse') + # 名称关联与单据处于同一测试批次范围,跨批次的主数据不会被匹配 + statement = ( + statement.outerjoin( + source_site, + and_( + source_site.site_id == BizReturnOrder.source_site_id, + batch_scope_condition(source_site.test_batch_id, scope), + ), + ) + .outerjoin( + source_customer, + and_( + source_customer.customer_id == source_site.customer_id, + batch_scope_condition(source_customer.test_batch_id, scope), + ), + ) + .outerjoin( + target_warehouse, + and_( + target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id, + batch_scope_condition(target_warehouse.test_batch_id, scope), + ), + ) + ) + # 托盘编号按明细存在性匹配,不改变每张单据只出现一行 + pallet_match = ( + select(BizReturnItem.return_item_id) + .join( + BizPallet, + and_( + BizPallet.pallet_id == BizReturnItem.pallet_id, + batch_scope_condition(BizPallet.test_batch_id, scope), + ), + ) + .where( + BizReturnItem.return_id == BizReturnOrder.return_id, + batch_scope_condition(BizReturnItem.test_batch_id, scope), + BizPallet.pallet_code.like(keyword), + ) + .exists() + ) + conditions.append( + or_( + BizReturnOrder.return_no.like(keyword), + source_site.site_name.like(keyword), + source_customer.customer_name.like(keyword), + target_warehouse.warehouse_name.like(keyword), + pallet_match, + ) + ) + statement = statement.where(*conditions).order_by(BizReturnOrder.return_id) return await cls._paginate(db, statement, query_object, is_page) @classmethod diff --git a/shierfeng-fastapi-backend/module_business/dao/return_summary_dao.py b/shierfeng-fastapi-backend/module_business/dao/return_summary_dao.py new file mode 100644 index 0000000..a52b595 --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/dao/return_summary_dao.py @@ -0,0 +1,280 @@ +from datetime import datetime +from typing import Any + +from sqlalchemy import and_, case, false, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from module_admin.entity.do.user_do import SysUser +from module_business.dao.operation_dao import OperationDao +from module_business.domain.enums import ReturnStatus +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition +from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizTunnel, BizWarehouse +from module_business.entity.do.operation_do import BizReturnItem, BizReturnOrder + + +class ReturnSummaryDao: + """托盘退回的只读汇总:列表名称与托盘汇总、详情托盘编号与隧道名称、来源现场选项、页面概况。 + + 列表与详情只汇总调用方已按数据范围取得的单据;概况与选项沿用列表的「来源现场或目标仓库」可见范围。 + 所有关联表均受同一测试批次范围约束。 + """ + + @staticmethod + def _filled_flag() -> Any: + # 申请时仍有余量的托盘,确认后回到有料库存;其余成为空托盘 + return case((BizReturnItem.remaining_packages_snapshot > 0, 1), else_=0) + + @staticmethod + async def get_list_summaries( + db: AsyncSession, + return_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = sorted(set(return_ids)) + if not ids: + return {} + + source_site = aliased(BizCustomerSite, name='source_site') + source_customer = aliased(BizCustomer, name='source_customer') + target_warehouse = aliased(BizWarehouse, name='target_warehouse') + requester = aliased(SysUser, name='requester') + confirmer = aliased(SysUser, name='confirmer') + name_statement = ( + select( + BizReturnOrder.return_id, + source_site.site_name, + source_customer.customer_name, + target_warehouse.warehouse_name, + requester.nick_name, + confirmer.nick_name, + ) + .select_from(BizReturnOrder) + .outerjoin( + source_site, + and_( + source_site.site_id == BizReturnOrder.source_site_id, + batch_scope_condition(source_site.test_batch_id, scope), + ), + ) + .outerjoin( + source_customer, + and_( + source_customer.customer_id == source_site.customer_id, + batch_scope_condition(source_customer.test_batch_id, scope), + ), + ) + .outerjoin( + target_warehouse, + and_( + target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id, + batch_scope_condition(target_warehouse.test_batch_id, scope), + ), + ) + .outerjoin(requester, and_(requester.user_id == BizReturnOrder.requested_by, requester.del_flag == '0')) + .outerjoin(confirmer, and_(confirmer.user_id == BizReturnOrder.confirmed_by, confirmer.del_flag == '0')) + .where( + BizReturnOrder.return_id.in_(ids), + batch_scope_condition(BizReturnOrder.test_batch_id, scope), + ) + ) + item_statement = ( + select( + BizReturnItem.return_id, + func.count(BizReturnItem.return_item_id), + func.sum(BizReturnItem.remaining_packages_snapshot), + func.sum(ReturnSummaryDao._filled_flag()), + ) + .where( + BizReturnItem.return_id.in_(ids), + batch_scope_condition(BizReturnItem.test_batch_id, scope), + ) + .group_by(BizReturnItem.return_id) + ) + + summaries: dict[int, dict[str, Any]] = { + return_id: { + 'source_site_name': None, + 'customer_name': None, + 'target_warehouse_name': None, + 'requester_name': None, + 'confirmer_name': None, + 'pallet_count': 0, + 'package_sum': 0, + 'filled_count': 0, + } + for return_id in ids + } + for return_id, site_name, customer_name, warehouse_name, requester_name, confirmer_name in ( + await db.execute(name_statement) + ).all(): + summary = summaries.get(return_id) + if summary is None: + continue + summary.update( + source_site_name=site_name, + customer_name=customer_name, + target_warehouse_name=warehouse_name, + requester_name=requester_name, + confirmer_name=confirmer_name, + ) + for return_id, pallet_count, package_sum, filled_count in (await db.execute(item_statement)).all(): + summary = summaries.get(return_id) + if summary is not None: + summary.update( + pallet_count=int(pallet_count or 0), + package_sum=int(package_sum or 0), + filled_count=int(filled_count or 0), + ) + return summaries + + @staticmethod + async def get_item_identities( + db: AsyncSession, + return_id: int, + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + """为详情明细补充托盘编号与申请时所在隧道名称,只读展示,不参与任何写入或核对。""" + statement = ( + select(BizReturnItem.return_item_id, BizPallet.pallet_code, BizTunnel.tunnel_name) + .select_from(BizReturnItem) + .outerjoin( + BizPallet, + and_( + BizPallet.pallet_id == BizReturnItem.pallet_id, + batch_scope_condition(BizPallet.test_batch_id, scope), + ), + ) + .outerjoin( + BizTunnel, + and_( + BizTunnel.tunnel_id == BizReturnItem.tunnel_id_snapshot, + batch_scope_condition(BizTunnel.test_batch_id, scope), + ), + ) + .where( + BizReturnItem.return_id == return_id, + batch_scope_condition(BizReturnItem.test_batch_id, scope), + ) + ) + return { + return_item_id: {'pallet_code': pallet_code, 'tunnel_name': tunnel_name} + for return_item_id, pallet_code, tunnel_name in (await db.execute(statement)).all() + } + + @staticmethod + async def get_site_options( + db: AsyncSession, + site_ids: list[int] | None, + warehouse_ids: list[int] | None, + scope: TestBatchScope, + ) -> list[dict[str, Any]]: + """来源现场选项:用户授权的现场(可发起退回),加上其可见退回单涉及的来源现场(用于筛选)。 + + 停用的现场仍保留,以便筛选历史单据。 + """ + conditions = [batch_scope_condition(BizCustomerSite.test_batch_id, scope)] + if site_ids is not None or warehouse_ids is not None: + scoped_site_ids = sorted(set(site_ids or [])) + visible_sources = select(BizReturnOrder.source_site_id).where( + # 与列表接口使用同一可见范围 + *OperationDao._return_scope_conditions(site_ids, warehouse_ids, scope) + ) + conditions.append( + or_( + BizCustomerSite.site_id.in_(scoped_site_ids) if scoped_site_ids else false(), + BizCustomerSite.site_id.in_(visible_sources), + ) + ) + statement = ( + select( + BizCustomerSite.site_id, + BizCustomerSite.site_code, + BizCustomerSite.site_name, + BizCustomer.customer_name, + ) + .select_from(BizCustomerSite) + .outerjoin( + BizCustomer, + and_( + BizCustomer.customer_id == BizCustomerSite.customer_id, + batch_scope_condition(BizCustomer.test_batch_id, scope), + ), + ) + .where(*conditions) + .order_by(BizCustomerSite.site_id) + ) + return [ + {'id': site_id, 'code': site_code, 'name': site_name, 'customer_name': customer_name} + for site_id, site_code, site_name, customer_name in (await db.execute(statement)).all() + ] + + @staticmethod + async def get_overview( + db: AsyncSession, + site_ids: list[int] | None, + warehouse_ids: list[int] | None, + scope: TestBatchScope, + month_start: datetime, + ) -> dict[str, Any]: + """退回页概况。待核对托盘即当前处于「退回中」的托盘,余量取申请时快照(确认前不会变化)。""" + visibility = OperationDao._return_scope_conditions(site_ids, warehouse_ids, scope) + item_join = and_( + BizReturnItem.return_id == BizReturnOrder.return_id, + batch_scope_condition(BizReturnItem.test_batch_id, scope), + ) + filled = ReturnSummaryDao._filled_flag() + pending_statement = ( + select( + func.count(func.distinct(BizReturnOrder.return_id)), + func.count(BizReturnItem.return_item_id), + func.sum(filled), + func.sum(BizReturnItem.remaining_packages_snapshot), + func.min(BizReturnOrder.requested_at), + ) + .select_from(BizReturnOrder) + .outerjoin(BizReturnItem, item_join) + .where(*visibility, BizReturnOrder.status == ReturnStatus.REQUESTED.value) + ) + # 按单据分组取回申请与确认时间,平均耗时在应用侧计算,避免依赖数据库的日期差函数 + month_statement = ( + select( + BizReturnOrder.return_id, + BizReturnOrder.requested_at, + BizReturnOrder.confirmed_at, + func.count(BizReturnItem.return_item_id), + func.sum(filled), + func.sum(BizReturnItem.remaining_packages_snapshot), + ) + .select_from(BizReturnOrder) + .outerjoin(BizReturnItem, item_join) + .where( + *visibility, + BizReturnOrder.status == ReturnStatus.CONFIRMED.value, + BizReturnOrder.confirmed_at >= month_start, + ) + .group_by(BizReturnOrder.return_id, BizReturnOrder.requested_at, BizReturnOrder.confirmed_at) + ) + + pending_count, pending_pallets, pending_filled, pending_packages, oldest_pending_at = ( + await db.execute(pending_statement) + ).one() + month_rows = (await db.execute(month_statement)).all() + durations = [ + (confirmed_at - requested_at).total_seconds() / 3600 + for _, requested_at, confirmed_at, *_ in month_rows + if requested_at is not None and confirmed_at is not None and confirmed_at >= requested_at + ] + return { + 'pending_count': int(pending_count or 0), + 'pending_pallet_count': int(pending_pallets or 0), + 'pending_filled_count': int(pending_filled or 0), + 'pending_package_sum': int(pending_packages or 0), + 'oldest_pending_at': oldest_pending_at, + 'month_start': month_start.date(), + 'month_confirmed_count': len(month_rows), + 'month_confirmed_pallet_count': sum(int(row[3] or 0) for row in month_rows), + 'month_filled_pallet_count': sum(int(row[4] or 0) for row in month_rows), + 'month_package_sum': sum(int(row[5] or 0) for row in month_rows), + 'month_avg_confirm_hours': round(sum(durations) / len(durations), 1) if durations else None, + } diff --git a/shierfeng-fastapi-backend/module_business/dao/shipment_summary_dao.py b/shierfeng-fastapi-backend/module_business/dao/shipment_summary_dao.py new file mode 100644 index 0000000..3b84eae --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/dao/shipment_summary_dao.py @@ -0,0 +1,167 @@ +from typing import Any + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from module_admin.entity.do.user_do import SysUser +from module_business.domain.enums import DocumentStatus, PalletFlowState +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition +from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizWarehouse +from module_business.entity.do.operation_do import ( + BizFillingRecord, + BizPalletInventory, + BizShipmentItem, + BizShipmentOrder, +) + + +class ShipmentSummaryDao: + """发货单列表的只读汇总:名称、托盘数、包数与菌种构成。 + + 只汇总调用方已按数据范围分页得到的单据 ID,所有关联表均受同一测试批次范围约束。 + """ + + @staticmethod + async def get_list_summaries( + db: AsyncSession, + shipment_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = sorted(set(shipment_ids)) + if not ids: + return {} + + source_warehouse = aliased(BizWarehouse, name='source_warehouse') + target_site = aliased(BizCustomerSite, name='target_site') + target_customer = aliased(BizCustomer, name='target_customer') + creator = aliased(SysUser, name='creator') + name_statement = ( + select( + BizShipmentOrder.shipment_id, + source_warehouse.warehouse_name, + target_site.site_name, + target_customer.customer_name, + creator.nick_name, + ) + .select_from(BizShipmentOrder) + .outerjoin( + source_warehouse, + and_( + source_warehouse.warehouse_id == BizShipmentOrder.source_warehouse_id, + batch_scope_condition(source_warehouse.test_batch_id, scope), + ), + ) + .outerjoin( + target_site, + and_( + target_site.site_id == BizShipmentOrder.target_site_id, + batch_scope_condition(target_site.test_batch_id, scope), + ), + ) + .outerjoin( + target_customer, + and_( + target_customer.customer_id == target_site.customer_id, + batch_scope_condition(target_customer.test_batch_id, scope), + ), + ) + .outerjoin(creator, and_(creator.user_name == BizShipmentOrder.create_by, creator.del_flag == '0')) + .where( + BizShipmentOrder.shipment_id.in_(ids), + batch_scope_condition(BizShipmentOrder.test_batch_id, scope), + ) + ) + + # 已确认单使用确认快照;草稿与详情一致,只取来源仓库内仍可发货的当前余量 + draft_inventory = and_( + BizShipmentOrder.status == DocumentStatus.DRAFT.value, + BizPalletInventory.pallet_id == BizShipmentItem.pallet_id, + BizPalletInventory.warehouse_id == BizShipmentOrder.source_warehouse_id, + BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value, + BizPalletInventory.site_id.is_(None), + BizPalletInventory.current_line_id.is_(None), + BizPalletInventory.active_filling_id.is_not(None), + BizPalletInventory.remaining_packages > 0, + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + item_statement = ( + select( + BizShipmentItem.shipment_id, + BizShipmentItem.package_qty, + BizPalletInventory.remaining_packages, + BizFillingRecord.strain_code_snapshot, + BizFillingRecord.strain_name_snapshot, + ) + .select_from(BizShipmentItem) + .join( + BizShipmentOrder, + and_( + BizShipmentOrder.shipment_id == BizShipmentItem.shipment_id, + batch_scope_condition(BizShipmentOrder.test_batch_id, scope), + ), + ) + .outerjoin(BizPalletInventory, draft_inventory) + .outerjoin( + BizFillingRecord, + and_( + BizFillingRecord.filling_id + == func.coalesce(BizShipmentItem.active_filling_id, BizPalletInventory.active_filling_id), + BizFillingRecord.pallet_id == BizShipmentItem.pallet_id, + batch_scope_condition(BizFillingRecord.test_batch_id, scope), + ), + ) + .where( + BizShipmentItem.shipment_id.in_(ids), + batch_scope_condition(BizShipmentItem.test_batch_id, scope), + ) + .order_by(BizShipmentItem.shipment_id, BizShipmentItem.shipment_item_id) + ) + + summaries: dict[int, dict[str, Any]] = { + shipment_id: { + 'source_warehouse_name': None, + 'target_site_name': None, + 'customer_name': None, + 'creator_name': None, + 'pallet_count': 0, + 'package_qty': None, + 'strains': [], + } + for shipment_id in ids + } + for shipment_id, warehouse_name, site_name, customer_name, creator_name in ( + await db.execute(name_statement) + ).all(): + summary = summaries.get(shipment_id) + if summary is None: + continue + summary.update( + source_warehouse_name=warehouse_name, + target_site_name=site_name, + customer_name=customer_name, + creator_name=creator_name, + ) + + strain_counts: dict[int, dict[str, int]] = {} + for shipment_id, package_qty, remaining_packages, strain_code, strain_name in ( + await db.execute(item_statement) + ).all(): + summary = summaries.get(shipment_id) + if summary is None: + continue + summary['pallet_count'] += 1 + quantity = package_qty if package_qty is not None else remaining_packages + if quantity is not None: + summary['package_qty'] = (summary['package_qty'] or 0) + quantity + label = strain_name or strain_code + if label: + counts = strain_counts.setdefault(shipment_id, {}) + counts[label] = counts.get(label, 0) + 1 + + for shipment_id, counts in strain_counts.items(): + summaries[shipment_id]['strains'] = [ + {'strain_name': name, 'pallet_count': count} + for name, count in sorted(counts.items(), key=lambda entry: (-entry[1], entry[0])) + ] + return summaries diff --git a/shierfeng-fastapi-backend/module_business/dao/usage_fifo_dao.py b/shierfeng-fastapi-backend/module_business/dao/usage_fifo_dao.py index 6db7d24..5f21b23 100644 --- a/shierfeng-fastapi-backend/module_business/dao/usage_fifo_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/usage_fifo_dao.py @@ -2,17 +2,76 @@ from typing import Any -from sqlalchemy import and_, func, or_, select +from sqlalchemy import ColumnElement, Select, and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory +# 待使用队列每个菌种最多返回的托盘数;总数仍完整统计 +QUEUE_LIMIT_PER_STRAIN = 20 + class UsageFifoDao: @staticmethod + def _candidate_conditions( + *, + site_id: int, + strain_id: int | None, + tunnel_id: int | None, + trace_no: str | None, + scope: TestBatchScope, + ) -> list[ColumnElement[bool]]: + """建议与待使用队列共用的候选条件;strain_id 为空时不按菌种限制(仅队列使用)。""" + inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet + unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None)) + conditions = [ + inventory.site_id == site_id, + inventory.warehouse_id.is_(None), + inventory.flow_state.in_(['AT_SITE_UNUSED', 'AT_SITE_PARTIAL']), + inventory.remaining_packages > 0, + inventory.remaining_packages <= pallet.capacity_packages, + ] + if strain_id is not None: + conditions.append(filling.strain_id == strain_id) + conditions.extend( + [ + filling.active_slot == 'ACTIVE', + filling.closed_at.is_(None), + pallet.asset_status == '0', + batch_scope_condition(inventory.test_batch_id, scope), + batch_scope_condition(filling.test_batch_id, scope), + batch_scope_condition(pallet.test_batch_id, scope), + ( + unbound + if tunnel_id is None + else or_(unbound, and_(inventory.tunnel_id == tunnel_id, inventory.trace_no == trace_no)) + ), + ] + ) + return conditions + + @staticmethod + def _join_candidates(statement: Select) -> Select: + inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet + return ( + statement.select_from(inventory) + .join(pallet, pallet.pallet_id == inventory.pallet_id) + .join( + filling, + and_(filling.filling_id == inventory.active_filling_id, filling.pallet_id == inventory.pallet_id), + ) + ) + + @staticmethod + def _fifo_order() -> tuple[Any, ...]: + # 先到先用:按实际收货时间,同一时间按托盘ID稳定排序 + return (BizPalletInventory.site_arrived_at, BizPalletInventory.pallet_id) + + @classmethod async def recommendation( + cls, db: AsyncSession, *, site_id: int, @@ -23,27 +82,10 @@ class UsageFifoDao: for_update: bool = False, ) -> tuple[dict[str, Any] | None, int]: inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet - unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None)) - conditions = [ - inventory.site_id == site_id, - inventory.warehouse_id.is_(None), - inventory.flow_state.in_(['AT_SITE_UNUSED', 'AT_SITE_PARTIAL']), - inventory.remaining_packages > 0, - inventory.remaining_packages <= pallet.capacity_packages, - filling.strain_id == strain_id, - filling.active_slot == 'ACTIVE', - filling.closed_at.is_(None), - pallet.asset_status == '0', - batch_scope_condition(inventory.test_batch_id, scope), - batch_scope_condition(filling.test_batch_id, scope), - batch_scope_condition(pallet.test_batch_id, scope), - ( - unbound - if tunnel_id is None - else or_(unbound, and_(inventory.tunnel_id == tunnel_id, inventory.trace_no == trace_no)) - ), - ] - base = ( + conditions = cls._candidate_conditions( + site_id=site_id, strain_id=strain_id, tunnel_id=tunnel_id, trace_no=trace_no, scope=scope + ) + base = cls._join_candidates( select( pallet.pallet_id, pallet.pallet_code, @@ -51,22 +93,8 @@ class UsageFifoDao: inventory.remaining_packages, inventory.site_arrived_at, ) - .select_from(inventory) - .join(pallet, pallet.pallet_id == inventory.pallet_id) - .join( - filling, - and_(filling.filling_id == inventory.active_filling_id, filling.pallet_id == inventory.pallet_id), - ) - .where(*conditions) - ) - statement = ( - base.where(inventory.site_arrived_at.is_not(None)) - .order_by( - inventory.site_arrived_at, - inventory.pallet_id, - ) - .limit(1) - ) + ).where(*conditions) + statement = base.where(inventory.site_arrived_at.is_not(None)).order_by(*cls._fifo_order()).limit(1) if for_update: # 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。 # 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。 @@ -83,3 +111,88 @@ class UsageFifoDao: ) ) return (dict(first) if first is not None else None, unknown.scalar_one()) + + @classmethod + async def queue( + cls, + db: AsyncSession, + *, + site_id: int, + scope: TestBatchScope, + limit_per_strain: int = QUEUE_LIMIT_PER_STRAIN, + ) -> list[dict[str, Any]]: + """现场尚未入隧的待使用托盘,按菌种分组;组内顺序与先到先用建议一致(只读)。""" + inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet + conditions = cls._candidate_conditions( + site_id=site_id, strain_id=None, tunnel_id=None, trace_no=None, scope=scope + ) + known_statement = ( + cls._join_candidates( + select( + filling.strain_id, + filling.strain_code_snapshot, + filling.strain_name_snapshot, + pallet.pallet_id, + pallet.pallet_code, + filling.batch_no, + filling.expiry_date, + inventory.remaining_packages, + inventory.site_arrived_at, + ) + ) + .where(*conditions, inventory.site_arrived_at.is_not(None)) + .order_by(*cls._fifo_order()) + ) + unknown_statement = ( + cls._join_candidates( + select( + filling.strain_id, + func.max(filling.strain_code_snapshot), + func.max(filling.strain_name_snapshot), + func.count(inventory.pallet_id), + ) + ) + .where(*conditions, inventory.site_arrived_at.is_(None)) + .group_by(filling.strain_id) + ) + + groups: dict[int, dict[str, Any]] = {} + + def group_for(strain_id: int, strain_code: str | None, strain_name: str | None) -> dict[str, Any]: + group = groups.get(strain_id) + if group is None: + group = { + 'strain_id': strain_id, + 'strain_code': strain_code, + 'strain_name': strain_name, + 'pallet_count': 0, + 'unknown_arrival_count': 0, + 'pallets': [], + } + groups[strain_id] = group + return group + + for row in (await db.execute(known_statement)).mappings().all(): + group = group_for(row['strain_id'], row['strain_code_snapshot'], row['strain_name_snapshot']) + group['pallet_count'] += 1 + if len(group['pallets']) < limit_per_strain: + group['pallets'].append( + { + 'pallet_id': row['pallet_id'], + 'pallet_code': row['pallet_code'], + 'batch_no': row['batch_no'], + 'expiry_date': row['expiry_date'], + 'remaining_packages': row['remaining_packages'], + 'site_arrived_at': row['site_arrived_at'], + } + ) + for strain_id, strain_code, strain_name, count in (await db.execute(unknown_statement)).all(): + group_for(strain_id, strain_code, strain_name)['unknown_arrival_count'] = int(count or 0) + return sorted( + groups.values(), + key=lambda group: ( + -(group['pallet_count'] + group['unknown_arrival_count']), + group['strain_name'] or '', + group['strain_id'], + ), + ) diff --git a/shierfeng-fastapi-backend/module_business/dao/usage_summary_dao.py b/shierfeng-fastapi-backend/module_business/dao/usage_summary_dao.py new file mode 100644 index 0000000..2269e8e --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/dao/usage_summary_dao.py @@ -0,0 +1,358 @@ +from datetime import datetime +from typing import Any + +from sqlalchemy import and_, case, false, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from module_admin.entity.do.user_do import SysUser +from module_business.domain.enums import PalletFlowState +from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition +from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizProductionLine, BizTunnel +from module_business.entity.do.operation_do import BizPalletInventory, BizUsageRecord + +_IN_USE_STATES = (PalletFlowState.AT_SITE_UNUSED.value, PalletFlowState.AT_SITE_PARTIAL.value) + + +class UsageSummaryDao: + """客户现场使用的只读汇总:列表名称、现场选项与现场看板。 + + 不返回隧道内部溯源号;所有关联表均受同一测试批次范围约束。 + """ + + @staticmethod + def _skipped_pallet_code(snapshot: Any) -> str | None: + # 跳过先到托盘时,写入路径保存了当时的先到托盘快照 + if isinstance(snapshot, dict): + code = snapshot.get('palletCode') + return code if isinstance(code, str) and code.strip() else None + return None + + @classmethod + async def get_list_summaries( + cls, + db: AsyncSession, + usage_ids: list[int], + scope: TestBatchScope, + ) -> dict[int, dict[str, Any]]: + ids = sorted(set(usage_ids)) + if not ids: + return {} + statement = ( + select( + BizUsageRecord.usage_id, + BizPallet.pallet_code, + BizCustomerSite.site_name, + BizTunnel.tunnel_code, + BizTunnel.tunnel_name, + BizProductionLine.line_name, + SysUser.nick_name, + BizUsageRecord.fifo_recommendation, + ) + .select_from(BizUsageRecord) + .outerjoin( + BizPallet, + and_( + BizPallet.pallet_id == BizUsageRecord.pallet_id, + batch_scope_condition(BizPallet.test_batch_id, scope), + ), + ) + .outerjoin( + BizCustomerSite, + and_( + BizCustomerSite.site_id == BizUsageRecord.site_id, + batch_scope_condition(BizCustomerSite.test_batch_id, scope), + ), + ) + .outerjoin( + BizTunnel, + and_( + BizTunnel.tunnel_id == BizUsageRecord.tunnel_id, + batch_scope_condition(BizTunnel.test_batch_id, scope), + ), + ) + .outerjoin( + BizProductionLine, + and_( + BizProductionLine.line_id == BizUsageRecord.line_id, + batch_scope_condition(BizProductionLine.test_batch_id, scope), + ), + ) + .outerjoin(SysUser, and_(SysUser.user_id == BizUsageRecord.operator_id, SysUser.del_flag == '0')) + .where( + BizUsageRecord.usage_id.in_(ids), + batch_scope_condition(BizUsageRecord.test_batch_id, scope), + ) + ) + summaries: dict[int, dict[str, Any]] = { + usage_id: { + 'pallet_code': None, + 'site_name': None, + 'tunnel_code': None, + 'tunnel_name': None, + 'line_name': None, + 'operator_name': None, + 'skipped_pallet_code': None, + } + for usage_id in ids + } + for usage_id, pallet_code, site_name, tunnel_code, tunnel_name, line_name, operator_name, snapshot in ( + await db.execute(statement) + ).all(): + summary = summaries.get(usage_id) + if summary is None: + continue + summary.update( + pallet_code=pallet_code, + site_name=site_name, + tunnel_code=tunnel_code, + tunnel_name=tunnel_name, + line_name=line_name, + operator_name=operator_name, + skipped_pallet_code=cls._skipped_pallet_code(snapshot), + ) + return summaries + + @staticmethod + async def get_site_options( + db: AsyncSession, + site_ids: list[int] | None, + scope: TestBatchScope, + ) -> list[dict[str, Any]]: + """当前用户有权访问且已启用的现场,附带启用隧道与产线数。""" + conditions = [batch_scope_condition(BizCustomerSite.test_batch_id, scope), BizCustomerSite.status == '0'] + if site_ids is not None: + scoped_ids = sorted(set(site_ids)) + conditions.append(BizCustomerSite.site_id.in_(scoped_ids) if scoped_ids else false()) + tunnel_counts = ( + select(BizTunnel.site_id, func.count(BizTunnel.tunnel_id).label('tunnel_count')) + .where(BizTunnel.status == '0', batch_scope_condition(BizTunnel.test_batch_id, scope)) + .group_by(BizTunnel.site_id) + .subquery() + ) + line_counts = ( + select(BizProductionLine.site_id, func.count(BizProductionLine.line_id).label('line_count')) + .where(BizProductionLine.status == '0', batch_scope_condition(BizProductionLine.test_batch_id, scope)) + .group_by(BizProductionLine.site_id) + .subquery() + ) + statement = ( + select( + BizCustomerSite.site_id, + BizCustomerSite.site_code, + BizCustomerSite.site_name, + BizCustomer.customer_name, + tunnel_counts.c.tunnel_count, + line_counts.c.line_count, + ) + .select_from(BizCustomerSite) + .outerjoin( + BizCustomer, + and_( + BizCustomer.customer_id == BizCustomerSite.customer_id, + batch_scope_condition(BizCustomer.test_batch_id, scope), + ), + ) + .outerjoin(tunnel_counts, tunnel_counts.c.site_id == BizCustomerSite.site_id) + .outerjoin(line_counts, line_counts.c.site_id == BizCustomerSite.site_id) + .where(*conditions) + .order_by(BizCustomerSite.site_id) + ) + return [ + { + 'id': site_id, + 'code': site_code, + 'name': site_name, + 'customer_name': customer_name, + 'tunnel_count': int(tunnel_count or 0), + 'line_count': int(line_count or 0), + } + for site_id, site_code, site_name, customer_name, tunnel_count, line_count in ( + await db.execute(statement) + ).all() + ] + + @staticmethod + async def get_site_board( + db: AsyncSession, + site: Any, + scope: TestBatchScope, + today_start: datetime, + week_start: datetime, + ) -> dict[str, Any]: + """调用方已校验现场权限与批次范围;这里只做只读统计。""" + site_id = site.site_id + customer_statement = select(BizCustomer.customer_name).where( + BizCustomer.customer_id == site.customer_id, + batch_scope_condition(BizCustomer.test_batch_id, scope), + ) + inventory_statement = ( + select( + BizPalletInventory.flow_state, + func.count(BizPalletInventory.pallet_id), + func.sum(BizPalletInventory.remaining_packages), + ) + .where( + BizPalletInventory.site_id == site_id, + BizPalletInventory.warehouse_id.is_(None), + BizPalletInventory.flow_state.in_( + [*_IN_USE_STATES, PalletFlowState.AT_SITE_EMPTY.value], + ), + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + .group_by(BizPalletInventory.flow_state) + ) + usage_conditions = [ + BizUsageRecord.site_id == site_id, + batch_scope_condition(BizUsageRecord.test_batch_id, scope), + ] + today_statement = ( + select( + BizUsageRecord.line_id, + BizProductionLine.line_name, + func.count(BizUsageRecord.usage_id), + func.sum(BizUsageRecord.used_packages), + ) + .select_from(BizUsageRecord) + .outerjoin( + BizProductionLine, + and_( + BizProductionLine.line_id == BizUsageRecord.line_id, + batch_scope_condition(BizProductionLine.test_batch_id, scope), + ), + ) + .where(*usage_conditions, BizUsageRecord.occurred_at >= today_start) + .group_by(BizUsageRecord.line_id, BizProductionLine.line_name) + ) + skip_statement = select( + func.sum(case((BizUsageRecord.occurred_at >= today_start, 1), else_=0)), + func.count(BizUsageRecord.usage_id), + ).where( + *usage_conditions, + BizUsageRecord.fifo_skip_reason.is_not(None), + BizUsageRecord.occurred_at >= week_start, + ) + tunnel_statement = ( + select( + BizTunnel.tunnel_id, + BizTunnel.tunnel_code, + BizTunnel.tunnel_name, + BizTunnel.current_trace_no.is_not(None).label('in_use'), + BizTunnel.current_trace_started_at, + ) + .where( + BizTunnel.site_id == site_id, + BizTunnel.status == '0', + batch_scope_condition(BizTunnel.test_batch_id, scope), + ) + .order_by(BizTunnel.tunnel_code, BizTunnel.tunnel_id) + ) + tunnel_load_statement = ( + select( + BizPalletInventory.tunnel_id, + func.count(BizPalletInventory.pallet_id), + func.sum(BizPalletInventory.remaining_packages), + ) + .where( + BizPalletInventory.site_id == site_id, + BizPalletInventory.tunnel_id.is_not(None), + BizPalletInventory.flow_state.in_(_IN_USE_STATES), + BizPalletInventory.remaining_packages > 0, + batch_scope_condition(BizPalletInventory.test_batch_id, scope), + ) + .group_by(BizPalletInventory.tunnel_id) + ) + latest_usage = ( + select(BizUsageRecord.tunnel_id, func.max(BizUsageRecord.usage_id).label('usage_id')) + .where(*usage_conditions, BizUsageRecord.tunnel_id.is_not(None)) + .group_by(BizUsageRecord.tunnel_id) + .subquery() + ) + last_use_statement = ( + select(BizUsageRecord.tunnel_id, BizUsageRecord.occurred_at, BizProductionLine.line_name) + .select_from(latest_usage) + .join(BizUsageRecord, BizUsageRecord.usage_id == latest_usage.c.usage_id) + .outerjoin( + BizProductionLine, + and_( + BizProductionLine.line_id == BizUsageRecord.line_id, + batch_scope_condition(BizProductionLine.test_batch_id, scope), + ), + ) + ) + line_statement = ( + select(BizProductionLine.line_id, BizProductionLine.line_code, BizProductionLine.line_name) + .where( + BizProductionLine.site_id == site_id, + BizProductionLine.status == '0', + batch_scope_condition(BizProductionLine.test_batch_id, scope), + ) + .order_by(BizProductionLine.line_id) + ) + + customer_name = (await db.execute(customer_statement)).scalar_one_or_none() + stock = { + state: (int(count or 0), int(packages or 0)) + for state, count, packages in (await db.execute(inventory_statement)).all() + } + today_rows = (await db.execute(today_statement)).all() + today_skips, week_skips = (await db.execute(skip_statement)).one() + tunnel_rows = (await db.execute(tunnel_statement)).all() + loads = { + tunnel_id: (int(count or 0), int(packages or 0)) + for tunnel_id, count, packages in (await db.execute(tunnel_load_statement)).all() + } + last_uses = { + tunnel_id: (occurred_at, line_name) + for tunnel_id, occurred_at, line_name in (await db.execute(last_use_statement)).all() + } + lines = [ + {'id': line_id, 'code': line_code, 'name': line_name} + for line_id, line_code, line_name in (await db.execute(line_statement)).all() + ] + + unused = stock.get(PalletFlowState.AT_SITE_UNUSED.value, (0, 0)) + partial = stock.get(PalletFlowState.AT_SITE_PARTIAL.value, (0, 0)) + by_line = sorted( + ( + {'line_id': line_id, 'line_name': line_name, 'used_packages': int(packages or 0)} + for line_id, line_name, _, packages in today_rows + ), + key=lambda row: (-row['used_packages'], row['line_id'] or 0), + ) + tunnels = [] + for tunnel_id, tunnel_code, tunnel_name, in_use, started_at in tunnel_rows: + pallet_count, remaining = loads.get(tunnel_id, (0, 0)) + last_used_at, last_line_name = last_uses.get(tunnel_id, (None, None)) + tunnels.append( + { + 'tunnel_id': tunnel_id, + 'tunnel_code': tunnel_code, + 'tunnel_name': tunnel_name, + 'in_use': bool(in_use), + 'pallet_count': pallet_count, + 'remaining_packages': remaining, + 'started_at': started_at if in_use else None, + 'last_used_at': last_used_at, + 'last_line_name': last_line_name, + } + ) + return { + 'site_id': site_id, + 'site_code': site.site_code, + 'site_name': site.site_name, + 'customer_name': customer_name, + 'pallet_count': unused[0] + partial[0], + 'package_sum': unused[1] + partial[1], + 'unused_pallet_count': unused[0], + 'partial_pallet_count': partial[0], + 'empty_pallet_count': stock.get(PalletFlowState.AT_SITE_EMPTY.value, (0, 0))[0], + 'today_usage_count': sum(int(row[2] or 0) for row in today_rows), + 'today_used_packages': sum(row['used_packages'] for row in by_line), + 'today_by_line': by_line, + 'tunnel_count': len(tunnels), + 'active_tunnel_count': sum(1 for tunnel in tunnels if tunnel['in_use']), + 'today_fifo_skip_count': int(today_skips or 0), + 'week_fifo_skip_count': int(week_skips or 0), + 'tunnels': tunnels, + 'lines': lines, + } diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/master_summary_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/master_summary_vo.py new file mode 100644 index 0000000..f4a760b --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/entity/vo/master_summary_vo.py @@ -0,0 +1,217 @@ +from datetime import date, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from module_business.entity.vo.master_vo import ( + CustomerModel, + LineModel, + PalletModel, + SiteModel, + StrainModel, + TunnelModel, + WarehouseModel, +) + + +class _ReadModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + +class MasterUserBriefModel(_ReadModel): + """已分配到仓库或客户现场的用户摘要。""" + + user_id: int = Field(description='用户ID') + user_name: str = Field(description='用户账号') + nick_name: str | None = Field(default=None, description='用户昵称') + + +class StrainListItemModel(StrainModel): + """菌种列表行:主数据加当前在库与在外的物料量。""" + + warehouse_pallet_count: int = Field(default=0, description='仓库有料托盘数') + warehouse_package_qty: int = Field(default=0, description='仓库有料包数') + field_pallet_count: int = Field(default=0, description='在途与客户现场有料托盘数') + field_package_qty: int = Field(default=0, description='在途与客户现场剩余包数') + nearest_expiry_date: date | None = Field(default=None, description='仓库在库物料最近有效期') + last_filled_at: datetime | None = Field(default=None, description='最近装填时间') + filling_count_30d: int = Field(default=0, description='近30天装填次数') + + +class CustomerListItemModel(CustomerModel): + """客户列表行:主数据加现场层级与在用托盘规模。""" + + site_count: int = Field(default=0, description='现场数') + line_count: int = Field(default=0, description='产线数') + tunnel_count: int = Field(default=0, description='隧道数') + site_pallet_count: int = Field(default=0, description='客户现场托盘数') + transit_pallet_count: int = Field(default=0, description='在途托盘数') + user_count: int = Field(default=0, description='已分配现场用户数') + + +class CustomerSiteLineModel(_ReadModel): + line_id: int = Field(description='产线ID') + line_code: str = Field(description='产线编码') + line_name: str = Field(description='产线名称') + status: str = Field(description='状态') + active_pallet_count: int = Field(default=0, description='产线上的托盘数') + + +class CustomerSiteTunnelModel(_ReadModel): + tunnel_id: int = Field(description='隧道ID') + tunnel_code: str = Field(description='隧道编号') + tunnel_name: str = Field(description='隧道名称') + status: str = Field(description='状态') + current_trace_no: str | None = Field(default=None, description='当前物料溯源号') + current_trace_started_at: datetime | None = Field(default=None, description='当前溯源批次开始时间') + active_pallet_count: int = Field(default=0, description='隧道内托盘数') + + +class CustomerSiteNodeModel(_ReadModel): + """客户详情中的一个现场节点。""" + + site_id: int = Field(description='现场ID') + site_code: str = Field(description='现场编码') + site_name: str = Field(description='现场名称') + address: str | None = Field(default=None, description='现场地址') + status: str = Field(description='状态') + unused_pallet_count: int = Field(default=0, description='现场未使用托盘数') + partial_pallet_count: int = Field(default=0, description='现场部分使用托盘数') + empty_pallet_count: int = Field(default=0, description='现场空托盘数') + transit_pallet_count: int = Field(default=0, description='在途托盘数') + remaining_package_qty: int = Field(default=0, description='现场剩余包数') + lines: list[CustomerSiteLineModel] = Field(default_factory=list, description='产线') + tunnels: list[CustomerSiteTunnelModel] = Field(default_factory=list, description='隧道') + users: list[MasterUserBriefModel] = Field(default_factory=list, description='已分配用户') + + +class CustomerDetailModel(CustomerModel): + """客户详情:主数据加现场、产线、隧道、托盘与用户层级。""" + + sites: list[CustomerSiteNodeModel] = Field(default_factory=list, description='现场层级') + + +class SiteListItemModel(SiteModel): + """客户现场列表行:主数据加所属客户、层级规模、托盘与授权用户。""" + + customer_code: str | None = Field(default=None, description='客户编码') + customer_name: str | None = Field(default=None, description='客户名称') + line_count: int = Field(default=0, description='产线数') + tunnel_count: int = Field(default=0, description='隧道数') + unused_pallet_count: int = Field(default=0, description='现场未使用托盘数') + partial_pallet_count: int = Field(default=0, description='现场部分使用托盘数') + empty_pallet_count: int = Field(default=0, description='现场空托盘数') + transit_pallet_count: int = Field(default=0, description='在途托盘数') + remaining_package_qty: int = Field(default=0, description='现场剩余包数') + user_count: int = Field(default=0, description='已分配用户数') + users: list[MasterUserBriefModel] = Field(default_factory=list, description='已分配用户(前5位)') + + +class LineListItemModel(LineModel): + """产线列表行:主数据加所属现场与近期使用。""" + + site_code: str | None = Field(default=None, description='现场编码') + site_name: str | None = Field(default=None, description='现场名称') + customer_name: str | None = Field(default=None, description='客户名称') + active_pallet_count: int = Field(default=0, description='产线上的托盘数') + usage_count_30d: int = Field(default=0, description='近30天使用次数') + used_package_qty_30d: int = Field(default=0, description='近30天使用包数') + last_used_at: datetime | None = Field(default=None, description='最近使用时间') + + +class TunnelListItemModel(TunnelModel): + """隧道列表行:主数据加所属现场、隧道内托盘与近期使用。""" + + site_code: str | None = Field(default=None, description='现场编码') + site_name: str | None = Field(default=None, description='现场名称') + customer_name: str | None = Field(default=None, description='客户名称') + active_pallet_count: int = Field(default=0, description='隧道内托盘数') + active_package_qty: int = Field(default=0, description='隧道内剩余包数') + usage_count_30d: int = Field(default=0, description='近30天使用次数') + last_used_at: datetime | None = Field(default=None, description='最近使用时间') + + +class WarehouseListItemModel(WarehouseModel): + """仓库列表行:主数据加库存、今日作业、待处理与授权用户。""" + + filled_pallet_count: int = Field(default=0, description='有料托盘数') + filled_package_qty: int = Field(default=0, description='库存包数') + empty_pallet_count: int = Field(default=0, description='空托盘数') + expiring_pallet_count: int = Field(default=0, description='7天内到期托盘数') + today_filling_count: int = Field(default=0, description='今日装填托盘数') + today_shipment_pallet_count: int = Field(default=0, description='今日发货托盘数') + draft_count: int = Field(default=0, description='待确认入库与发货单数') + incoming_return_count: int = Field(default=0, description='待确认退回单数') + user_count: int = Field(default=0, description='已分配用户数') + users: list[MasterUserBriefModel] = Field(default_factory=list, description='已分配用户(前5位)') + + +class PalletListItemModel(PalletModel): + """托盘列表行:资产主数据加当前流转状态与位置。""" + + flow_state: str | None = Field(default=None, description='当前流转状态') + remaining_packages: int = Field(default=0, description='当前剩余包数') + warehouse_name: str | None = Field(default=None, description='所在仓库') + customer_name: str | None = Field(default=None, description='所在或目标客户') + site_name: str | None = Field(default=None, description='所在或目标现场') + line_name: str | None = Field(default=None, description='当前产线') + tunnel_name: str | None = Field(default=None, description='当前隧道') + return_warehouse_name: str | None = Field(default=None, description='退回目标仓库') + strain_name: str | None = Field(default=None, description='当前菌种') + batch_no: str | None = Field(default=None, description='当前生产批次') + expiry_date: date | None = Field(default=None, description='当前物料有效期') + cycle_count: int = Field(default=0, description='累计发货周转次数') + last_shipped_at: datetime | None = Field(default=None, description='最近一次发货时间') + last_event_at: datetime | None = Field(default=None, description='最近一次状态变化时间') + + +class PalletEventBriefModel(_ReadModel): + event_id: int = Field(description='事件ID') + event_type: str = Field(description='事件类型') + occurred_at: datetime = Field(description='发生时间') + after_state: str = Field(description='变更后状态') + before_remaining_packages: int = Field(description='变更前剩余包数') + after_remaining_packages: int = Field(description='变更后剩余包数') + trace_no: str | None = Field(default=None, description='物料溯源号') + warehouse_name: str | None = Field(default=None, description='仓库') + customer_name: str | None = Field(default=None, description='客户') + site_name: str | None = Field(default=None, description='现场') + line_name: str | None = Field(default=None, description='产线') + tunnel_name: str | None = Field(default=None, description='隧道') + operator_name: str | None = Field(default=None, description='操作人') + + +class PalletDetailModel(PalletListItemModel): + """托盘详情:列表行信息加最近流转事件。""" + + recent_events: list[PalletEventBriefModel] = Field(default_factory=list, description='最近流转事件') + + +class MasterImportPreviewRowModel(_ReadModel): + excel_row: int = Field(description='Excel 行号') + outcome: Literal['add', 'update', 'skip', 'error'] = Field(description='导入时将发生的结果') + message: str = Field(description='校验说明') + values: list[str] = Field(description='按表头顺序的单元格文本') + + +class MasterImportPreviewModel(_ReadModel): + """导入校验预览:按真实导入流程逐行校验后整体回滚,不写入任何数据。""" + + headers: list[str] = Field(description='模板表头') + update_support: bool = Field(description='编码已存在时是否更新') + total_rows: int = Field(description='数据行数') + add_count: int = Field(description='将新增行数') + update_count: int = Field(description='将更新行数') + skip_count: int = Field(description='将跳过行数') + error_count: int = Field(description='有错误行数') + rows: list[MasterImportPreviewRowModel] = Field(description='逐行结果') + + +class MasterParentOptionModel(_ReadModel): + """基础资料表单中可选的上级资料(客户或客户现场)。""" + + id: int = Field(description='上级资料ID') + code: str = Field(description='上级资料编码') + name: str = Field(description='上级资料名称') + parent_name: str | None = Field(default=None, description='所属客户名称(现场选项)') diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py index c24fef7..3086bf1 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py @@ -422,6 +422,9 @@ class PalletPageQueryModel(BaseModel): page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') asset_status: Literal['0', '1'] | None = Field(default=None, description='资产状态') + flow_group: Literal['unstocked', 'warehouse', 'transit', 'site', 'returning'] | None = Field( + default=None, description='当前流转环节' + ) ids: str | None = Field(default=None, description='主键ID列表(英文逗号分隔)') diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py index 7039d81..aee9824 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py @@ -250,6 +250,8 @@ class InboundItemModel(BaseModel): inbound_item_id: int = Field(gt=0, description='入库明细ID') inbound_id: int = Field(gt=0, description='入库单ID') pallet_id: int = Field(gt=0, description='托盘ID') + pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)') + flow_state: PalletFlowState | None = Field(default=None, description='托盘当前流转状态(只读展示)') create_by: str | None = Field(default=None, max_length=64, description='创建者') create_time: datetime | None = Field(default=None, description='创建时间') @@ -275,6 +277,44 @@ class InboundModel(BaseModel): items: list[InboundItemModel] = Field(default_factory=list, description='入库托盘明细') +class InboundListItemModel(InboundModel): + """首次入库单列表行:在单据字段外附带只读的名称与托盘数。""" + + warehouse_name: str | None = Field(default=None, max_length=100, description='入库仓库名称') + creator_name: str | None = Field(default=None, max_length=30, description='创建人昵称') + confirmer_name: str | None = Field(default=None, max_length=30, description='确认人昵称') + pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数') + + +class InboundWarehouseCountModel(BaseModel): + """按仓库统计的托盘数。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + warehouse_id: int = Field(gt=0, description='仓库ID') + warehouse_name: str | None = Field(default=None, max_length=100, description='仓库名称') + pallet_count: int = Field(strict=True, ge=0, description='托盘数') + + +class InboundOverviewModel(BaseModel): + """首次入库页面概况(只读)。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + draft_count: int = Field(strict=True, ge=0, description='待确认草稿单数') + draft_pallet_count: int = Field(strict=True, ge=0, description='待确认草稿托盘数') + oldest_draft_at: datetime | None = Field(default=None, description='最早草稿创建时间') + month_start: date = Field(description='本月统计起始日期') + month_confirmed_count: int = Field(strict=True, ge=0, description='本月已确认单数') + month_confirmed_pallet_count: int = Field(strict=True, ge=0, description='本月已确认入库托盘数') + unstocked_pallet_count: int = Field(strict=True, ge=0, description='待首次入库托盘数(资产正常且未入库)') + empty_pallet_count: int = Field(strict=True, ge=0, description='授权仓库空托盘总数') + empty_by_warehouse: list[InboundWarehouseCountModel] = Field( + default_factory=list, + description='授权仓库空托盘按仓库分布,托盘数从多到少', + ) + + class FillingModel(BaseModel): """托盘装填记录展示模型。""" @@ -305,6 +345,68 @@ class FillingModel(BaseModel): remark: str | None = Field(default=None, max_length=500, description='备注') +class FillingListItemModel(FillingModel): + """托盘装填记录列表行:在记录字段外附带只读的托盘编号、仓库与操作人名称。""" + + pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号') + warehouse_name: str | None = Field(default=None, max_length=100, description='仓库名称') + operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称') + + +class FillingBatchModel(BaseModel): + """按菌种 + 生产批次聚合的装填汇总(只读)。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + strain_id: int = Field(gt=0, description='菌种ID') + strain_code: str | None = Field(default=None, max_length=64, description='菌种编码快照') + strain_name: str | None = Field(default=None, max_length=100, description='菌种名称快照') + batch_no: str = Field(min_length=1, max_length=64, description='生产批次') + production_date: date | None = Field(default=None, description='生产日期(批次内最早)') + expiry_date: date | None = Field(default=None, description='有效期至(批次内最早)') + package_spec: str | None = Field(default=None, max_length=64, description='最近一次装填的包装规格') + pallet_count: int = Field(strict=True, ge=0, description='已装填托盘数') + package_sum: int = Field(strict=True, ge=0, description='已装填包数') + last_operated_at: datetime | None = Field(default=None, description='最近装填时间') + last_operator_name: str | None = Field(default=None, max_length=30, description='最近装填操作人昵称') + + +class FillingBatchStateCountModel(BaseModel): + """批次托盘按当前去向统计。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + state: str = Field( + min_length=1, + max_length=32, + description='托盘当前流转状态;装填周期已结束(物料用完)时为 CONSUMED', + ) + pallet_count: int = Field(strict=True, ge=0, description='托盘数') + + +class FillingBatchRecordModel(BaseModel): + """批次内的一条装填记录(只读展示)。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + filling_id: int = Field(gt=0, description='装填记录ID') + filling_no: str = Field(min_length=1, max_length=64, description='装填单号') + pallet_id: int = Field(gt=0, description='托盘ID') + pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号') + warehouse_name: str | None = Field(default=None, max_length=100, description='装填仓库名称') + operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称') + operated_at: datetime = Field(description='装填时间') + + +class FillingBatchDetailModel(FillingBatchModel): + """生产批次详情:批次汇总、托盘当前去向与最近装填记录。""" + + distribution: list[FillingBatchStateCountModel] = Field( + default_factory=list, description='托盘当前去向,托盘数从多到少' + ) + recent: list[FillingBatchRecordModel] = Field(default_factory=list, description='最近 10 条装填记录') + + class ShipmentItemModel(BaseModel): """整托发货明细模型。""" @@ -321,6 +423,10 @@ class ShipmentItemModel(BaseModel): ge=0, description='当前库存剩余包数(只读,非确认快照)', ) + pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)') + strain_code: str | None = Field(default=None, max_length=64, description='菌种编码快照(只读展示)') + strain_name: str | None = Field(default=None, max_length=100, description='菌种名称快照(只读展示)') + batch_no: str | None = Field(default=None, max_length=64, description='生产批次(只读展示)') create_by: str | None = Field(default=None, max_length=64, description='创建者') create_time: datetime | None = Field(default=None, description='创建时间') @@ -350,6 +456,61 @@ class ShipmentModel(BaseModel): items: list[ShipmentItemModel] = Field(default_factory=list, description='发货托盘明细') +class ShipmentStrainSummaryModel(BaseModel): + """发货单内菌种构成。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + strain_name: str = Field(min_length=1, max_length=100, description='菌种名称或编码') + pallet_count: int = Field(strict=True, gt=0, description='托盘数') + + +class FillingOverviewModel(BaseModel): + """工厂装填页面概况(只读)。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + today_pallet_count: int = Field(strict=True, ge=0, description='今日装填托盘数') + today_package_sum: int = Field(strict=True, ge=0, description='今日装填包数') + today_batch_count: int = Field(strict=True, ge=0, description='今日涉及的生产批次数') + month_start: date = Field(description='本月统计起始日期') + month_pallet_count: int = Field(strict=True, ge=0, description='本月装填托盘数') + month_batch_count: int = Field(strict=True, ge=0, description='本月涉及的生产批次数') + month_top_strains: list[ShipmentStrainSummaryModel] = Field( + default_factory=list, + description='本月装填最多的菌种(最多 3 个)', + ) + empty_pallet_count: int = Field(strict=True, ge=0, description='授权仓库可装填空托盘总数') + empty_by_warehouse: list[InboundWarehouseCountModel] = Field( + default_factory=list, + description='授权仓库空托盘按仓库分布,托盘数从多到少', + ) + near_expiry_days: int = Field(strict=True, gt=0, description='临期判定天数') + near_expiry_pallet_count: int = Field( + strict=True, ge=0, description='有效期在临期天数内(含已过期)且仍在仓的有料托盘数' + ) + near_expiry_batch_count: int = Field(strict=True, ge=0, description='上述托盘涉及的生产批次数') + nearest_expiry_batch_no: str | None = Field(default=None, max_length=64, description='最早到期的在仓批次') + nearest_expiry_date: date | None = Field(default=None, description='最早到期的在仓批次有效期') + + +class ShipmentListItemModel(ShipmentModel): + """整托发货单列表行:在单据字段外附带只读的名称与汇总。""" + + source_warehouse_name: str | None = Field(default=None, max_length=100, description='来源仓库名称') + target_site_name: str | None = Field(default=None, max_length=100, description='目标现场名称') + customer_name: str | None = Field(default=None, max_length=100, description='客户名称') + creator_name: str | None = Field(default=None, max_length=30, description='创建人昵称') + pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数') + package_qty: int | None = Field( + default=None, + strict=True, + ge=0, + description='包数:已确认为确认快照之和,草稿为当前可发余量之和', + ) + strains: list[ShipmentStrainSummaryModel] = Field(default_factory=list, description='菌种构成') + + class UsageModel(BaseModel): """客户现场使用记录展示模型。""" @@ -373,6 +534,20 @@ class UsageModel(BaseModel): fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因') +class UsageListItemModel(UsageModel): + """客户现场使用记录列表行:附带只读的托盘、现场、隧道、产线与操作人名称。""" + + pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号') + site_name: str | None = Field(default=None, max_length=100, description='现场名称') + tunnel_code: str | None = Field(default=None, max_length=64, description='隧道编号') + tunnel_name: str | None = Field(default=None, max_length=100, description='隧道名称') + line_name: str | None = Field(default=None, max_length=100, description='产线名称') + operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称') + skipped_pallet_code: str | None = Field( + default=None, max_length=64, description='被跳过的先到托盘编号(取自使用时的先到先用快照)' + ) + + class ReturnItemModel(BaseModel): """托盘退回明细模型。""" @@ -386,6 +561,8 @@ class ReturnItemModel(BaseModel): trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照') batch_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时生产批次快照') remaining_packages_snapshot: int = Field(strict=True, ge=0, description='申请时剩余包数快照') + pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)') + tunnel_name: str | None = Field(default=None, max_length=100, description='申请时所在隧道名称(只读展示)') create_by: str | None = Field(default=None, max_length=64, description='创建者') create_time: datetime | None = Field(default=None, description='创建时间') @@ -416,6 +593,37 @@ class ReturnModel(BaseModel): items: list[ReturnItemModel] = Field(default_factory=list, description='退回托盘明细') +class ReturnListItemModel(ReturnModel): + """托盘退回单列表行:在单据字段外附带只读的名称与托盘汇总。""" + + source_site_name: str | None = Field(default=None, max_length=100, description='来源现场名称') + customer_name: str | None = Field(default=None, max_length=100, description='客户名称') + target_warehouse_name: str | None = Field(default=None, max_length=100, description='目标仓库名称') + requester_name: str | None = Field(default=None, max_length=30, description='申请人昵称') + confirmer_name: str | None = Field(default=None, max_length=30, description='确认人昵称') + pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数') + package_sum: int = Field(default=0, strict=True, ge=0, description='申请时剩余包数快照之和') + filled_count: int = Field(default=0, strict=True, ge=0, description='申请时仍有余量的托盘数') + + +class ReturnOverviewModel(BaseModel): + """托盘退回页面概况(只读)。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + pending_count: int = Field(strict=True, ge=0, description='待到仓核对单数') + pending_pallet_count: int = Field(strict=True, ge=0, description='待到仓核对托盘数(即退回中托盘)') + pending_filled_count: int = Field(strict=True, ge=0, description='待核对托盘中仍有余量的托盘数') + pending_package_sum: int = Field(strict=True, ge=0, description='待核对托盘剩余包数之和') + oldest_pending_at: datetime | None = Field(default=None, description='最早一张待核对单的申请时间') + month_start: date = Field(description='本月统计起始日期') + month_confirmed_count: int = Field(strict=True, ge=0, description='本月已确认单数') + month_confirmed_pallet_count: int = Field(strict=True, ge=0, description='本月已确认托盘数') + month_filled_pallet_count: int = Field(strict=True, ge=0, description='本月确认后回到有料库存的托盘数') + month_package_sum: int = Field(strict=True, ge=0, description='本月确认带回的剩余包数') + month_avg_confirm_hours: float | None = Field(default=None, ge=0, description='本月申请到确认的平均小时数') + + class InboundPageQueryModel(BaseModel): """首次入库分页查询模型。""" @@ -440,6 +648,13 @@ class FillingPageQueryModel(BaseModel): page_num: int = Field(default=1, gt=0, description='当前页码') page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') filling_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='装填单号') + keyword: str | None = Field( + default=None, + min_length=1, + max_length=64, + pattern=r'\S', + description='关键字:匹配装填单号、托盘编号或生产批次', + ) warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID') strain_id: int | None = Field(default=None, gt=0, description='菌种ID') batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='生产批次') @@ -450,6 +665,36 @@ class FillingPageQueryModel(BaseModel): _validate_date_range = model_validator(mode='after')(_validate_page_query_date_range) +class FillingBatchPageQueryModel(BaseModel): + """生产批次汇总分页查询模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + page_num: int = Field(default=1, gt=0, description='当前页码') + page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') + keyword: str | None = Field( + default=None, + min_length=1, + max_length=64, + pattern=r'\S', + description='关键字:匹配生产批次、菌种编码或名称', + ) + strain_id: int | None = Field(default=None, gt=0, description='菌种ID') + begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='装填开始日期') + end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='装填结束日期') + + _validate_date_range = model_validator(mode='after')(_validate_page_query_date_range) + + +class FillingBatchDetailQueryModel(BaseModel): + """生产批次详情查询模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + strain_id: int = Field(gt=0, description='菌种ID') + batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次') + + class ShipmentPageQueryModel(BaseModel): """整托发货分页查询模型。""" @@ -458,9 +703,17 @@ class ShipmentPageQueryModel(BaseModel): page_num: int = Field(default=1, gt=0, description='当前页码') page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') shipment_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='发货单号') + keyword: str | None = Field( + default=None, + min_length=1, + max_length=64, + pattern=r'\S', + description='关键字:匹配发货单号、来源仓库、目标现场或客户名称', + ) source_warehouse_id: int | None = Field(default=None, gt=0, description='来源仓库ID') target_site_id: int | None = Field(default=None, gt=0, description='目标现场ID') status: DocumentStatus | None = Field(default=None, description='单据状态') + receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = Field(default=None, description='收货核对状态') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') @@ -475,6 +728,14 @@ class UsagePageQueryModel(BaseModel): page_num: int = Field(default=1, gt=0, description='当前页码') page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') usage_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='使用记录号') + keyword: str | None = Field( + default=None, + min_length=1, + max_length=64, + pattern=r'\S', + description='关键字:匹配使用记录号或托盘编号', + ) + fifo_skipped: bool | None = Field(default=None, description='为 true 时只看跳过先到托盘的记录') site_id: int | None = Field(default=None, gt=0, description='现场ID') tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID') trace_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='物料溯源号') @@ -494,6 +755,13 @@ class ReturnPageQueryModel(BaseModel): page_num: int = Field(default=1, gt=0, description='当前页码') page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') return_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回单号') + keyword: str | None = Field( + default=None, + min_length=1, + max_length=64, + pattern=r'\S', + description='关键字:匹配退回单号、托盘编号、来源现场、客户或目标仓库名称', + ) source_site_id: int | None = Field(default=None, gt=0, description='来源现场ID') target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID') status: ReturnStatus | None = Field(default=None, description='退回状态') @@ -547,6 +815,71 @@ class BusinessOptionModel(BaseModel): name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='业务名称') +class ReturnSiteOptionModel(BusinessOptionModel): + """退回来源现场选项,附带所属客户名称。""" + + customer_name: str | None = Field(default=None, max_length=100, description='客户名称') + + +class UsageSiteOptionModel(BusinessOptionModel): + """现场使用页的现场选项,附带客户名称与启用隧道、产线数。""" + + customer_name: str | None = Field(default=None, max_length=100, description='客户名称') + tunnel_count: int = Field(default=0, strict=True, ge=0, description='启用隧道数') + line_count: int = Field(default=0, strict=True, ge=0, description='启用产线数') + + +class UsageBoardTunnelModel(BaseModel): + """隧道看板中的一条隧道(不含内部溯源号)。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + tunnel_id: int = Field(gt=0, description='隧道ID') + tunnel_code: str = Field(min_length=1, max_length=64, description='隧道编号') + tunnel_name: str = Field(min_length=1, max_length=100, description='隧道名称') + in_use: bool = Field(description='是否有正在使用的物料批') + pallet_count: int = Field(strict=True, ge=0, description='在隧道中仍有余量的托盘数') + remaining_packages: int = Field(strict=True, ge=0, description='在隧道中托盘的剩余包数') + started_at: datetime | None = Field(default=None, description='本轮物料批开始时间') + last_used_at: datetime | None = Field(default=None, description='最近一次登记使用时间') + last_line_name: str | None = Field(default=None, max_length=100, description='最近一次使用的产线名称') + + +class UsageLinePackagesModel(BaseModel): + """按产线统计的用量。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + line_id: int | None = Field(default=None, gt=0, description='产线ID;未指定产线时为空') + line_name: str | None = Field(default=None, max_length=100, description='产线名称') + used_packages: int = Field(strict=True, ge=0, description='使用包数') + + +class UsageSiteBoardModel(BaseModel): + """客户现场看板:库存、今日用量、隧道与先到先用概况(只读)。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + site_id: int = Field(gt=0, description='现场ID') + site_code: str | None = Field(default=None, max_length=64, description='现场编码') + site_name: str | None = Field(default=None, max_length=100, description='现场名称') + customer_name: str | None = Field(default=None, max_length=100, description='客户名称') + pallet_count: int = Field(strict=True, ge=0, description='现场未使用与使用中的托盘数') + package_sum: int = Field(strict=True, ge=0, description='上述托盘的剩余包数') + unused_pallet_count: int = Field(strict=True, ge=0, description='现场未使用托盘数') + partial_pallet_count: int = Field(strict=True, ge=0, description='现场使用中托盘数') + empty_pallet_count: int = Field(strict=True, ge=0, description='现场已用完、待退回的托盘数') + today_usage_count: int = Field(strict=True, ge=0, description='今日登记次数') + today_used_packages: int = Field(strict=True, ge=0, description='今日使用包数') + today_by_line: list[UsageLinePackagesModel] = Field(default_factory=list, description='今日用量按产线,从多到少') + tunnel_count: int = Field(strict=True, ge=0, description='启用隧道数') + active_tunnel_count: int = Field(strict=True, ge=0, description='正在使用的隧道数') + today_fifo_skip_count: int = Field(strict=True, ge=0, description='今日跳过先到托盘的使用次数') + week_fifo_skip_count: int = Field(strict=True, ge=0, description='近 7 天跳过先到托盘的使用次数') + tunnels: list[UsageBoardTunnelModel] = Field(default_factory=list, description='启用隧道,按编号排序') + lines: list[BusinessOptionModel] = Field(default_factory=list, description='启用产线,供筛选使用') + + class PalletScanResponseModel(BaseModel): """业务扫码托盘响应模型。""" diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/usage_fifo_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/usage_fifo_vo.py index 3c3c079..ea48a23 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/usage_fifo_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/usage_fifo_vo.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date, datetime from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -21,3 +21,34 @@ class UsageFifoRecommendationModel(BaseModel): unknown_arrival_count: int = 0 requires_skip_reason: bool = False message: str + + +class UsageFifoQueueItemModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + pallet_id: int + pallet_code: str + batch_no: str + remaining_packages: int + site_arrived_at: datetime + expiry_date: date | None = None + + +class UsageFifoStrainQueueModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + strain_id: int + strain_code: str | None = None + strain_name: str | None = None + pallet_count: int = 0 + unknown_arrival_count: int = 0 + pallets: list[UsageFifoQueueItemModel] = [] + + +class UsageFifoQueueModel(BaseModel): + """现场待使用托盘按菌种分组,组内与先到先用建议同序。""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + site_id: int + strains: list[UsageFifoStrainQueueModel] = [] diff --git a/shierfeng-fastapi-backend/module_business/service/filling_service.py b/shierfeng-fastapi-backend/module_business/service/filling_service.py index 222d482..75baa54 100644 --- a/shierfeng-fastapi-backend/module_business/service/filling_service.py +++ b/shierfeng-fastapi-backend/module_business/service/filling_service.py @@ -1,7 +1,7 @@ import hashlib import json import secrets -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, NoReturn from sqlalchemy.exc import IntegrityError @@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel from exceptions.exception import PermissionException, ServiceException from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.access_dao import AccessDao +from module_business.dao.filling_summary_dao import FillingSummaryDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao @@ -22,8 +23,14 @@ from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEv from module_business.entity.vo.master_vo import StrainPageQueryModel from module_business.entity.vo.operation_vo import ( BusinessOptionModel, + FillingBatchDetailModel, + FillingBatchDetailQueryModel, + FillingBatchModel, + FillingBatchPageQueryModel, FillingCreateModel, + FillingListItemModel, FillingModel, + FillingOverviewModel, FillingPageQueryModel, PalletScanResponseModel, ) @@ -34,6 +41,9 @@ from module_business.service.test_batch_access_service import TestBatchAccessSer class FillingService: """仓库内单托盘即时装填服务层。""" + # 有效期剩余不超过该天数的在仓批次视为临期 + NEAR_EXPIRY_DAYS = 14 + @staticmethod def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]: user = current_user.user @@ -187,7 +197,7 @@ class FillingService: current_user: CurrentUserModel, test_scope: TestBatchScope, is_page: bool = True, - ) -> PageModel | list[FillingModel]: + ) -> PageModel | list[FillingListItemModel]: warehouse_ids = await cls._warehouse_scope(query_db, current_user) if ( warehouse_ids is not None @@ -203,11 +213,81 @@ class FillingService: is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result - models = [FillingModel.model_validate(record) for record in rows] + records = [FillingModel.model_validate(record) for record in rows] + # 仅对本页已按数据范围筛出的记录补充托盘编号、仓库与操作人名称 + summaries = await FillingSummaryDao.get_list_summaries( + query_db, + [record.filling_id for record in records], + test_scope, + ) + models = [ + FillingListItemModel.model_validate({**record.model_dump(), **summaries.get(record.filling_id, {})}) + for record in records + ] if isinstance(result, PageModel): return result.model_copy(update={'rows': models}) return models + @classmethod + async def get_filling_batches_services( + cls, + query_db: AsyncSession, + query_object: FillingBatchPageQueryModel, + current_user: CurrentUserModel, + test_scope: TestBatchScope, + ) -> PageModel: + warehouse_ids = await cls._warehouse_scope(query_db, current_user) + batches, total = await FillingSummaryDao.get_batch_page(query_db, query_object, warehouse_ids, test_scope) + return PageModel( + rows=[FillingBatchModel.model_validate(batch) for batch in batches], + pageNum=query_object.page_num, + pageSize=query_object.page_size, + total=total, + hasNext=query_object.page_num * query_object.page_size < total, + ) + + @classmethod + async def get_filling_batch_detail_services( + cls, + query_db: AsyncSession, + query_object: FillingBatchDetailQueryModel, + current_user: CurrentUserModel, + test_scope: TestBatchScope, + ) -> FillingBatchDetailModel: + warehouse_ids = await cls._warehouse_scope(query_db, current_user) + batch = await FillingSummaryDao.get_batch_detail( + query_db, + query_object.strain_id, + query_object.batch_no, + warehouse_ids, + test_scope, + ) + if batch is None: + if cls._is_admin(current_user): + raise ServiceException(message='生产批次不存在') + raise PermissionException(data='', message='无权访问该生产批次') + return FillingBatchDetailModel.model_validate(batch) + + @classmethod + async def get_filling_overview_services( + cls, + query_db: AsyncSession, + current_user: CurrentUserModel, + test_scope: TestBatchScope, + ) -> FillingOverviewModel: + warehouse_ids = await cls._warehouse_scope(query_db, current_user) + today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + overview = await FillingSummaryDao.get_overview( + query_db, + warehouse_ids, + test_scope, + today_start, + today_start.replace(day=1), + (today_start + timedelta(days=cls.NEAR_EXPIRY_DAYS)).date(), + cls.NEAR_EXPIRY_DAYS, + ) + return FillingOverviewModel.model_validate(overview) + @classmethod async def filling_detail_services( cls, diff --git a/shierfeng-fastapi-backend/module_business/service/inbound_service.py b/shierfeng-fastapi-backend/module_business/service/inbound_service.py index 7f49e8c..d1be1c7 100644 --- a/shierfeng-fastapi-backend/module_business/service/inbound_service.py +++ b/shierfeng-fastapi-backend/module_business/service/inbound_service.py @@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel from exceptions.exception import PermissionException, ServiceException, ServiceWarning from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.access_dao import AccessDao +from module_business.dao.inbound_summary_dao import InboundSummaryDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao @@ -29,7 +30,10 @@ from module_business.entity.vo.operation_vo import ( BusinessOptionModel, ConfirmCommandModel, InboundCreateModel, + InboundItemModel, + InboundListItemModel, InboundModel, + InboundOverviewModel, InboundPageQueryModel, InboundScanQueryModel, InboundUpdateModel, @@ -245,7 +249,7 @@ class InboundService: current_user: CurrentUserModel, test_scope: TestBatchScope, is_page: bool = True, - ) -> PageModel | list[InboundModel]: + ) -> PageModel | list[InboundListItemModel]: warehouse_ids = await cls._warehouse_scope(query_db, current_user) if ( warehouse_ids is not None @@ -262,7 +266,17 @@ class InboundService: is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result - models = [InboundModel.model_validate(record) for record in rows] + records = [InboundModel.model_validate(record) for record in rows] + # 仅对本页已按数据范围筛出的单据补充名称与托盘数 + summaries = await InboundSummaryDao.get_list_summaries( + query_db, + [record.inbound_id for record in records], + test_scope, + ) + models = [ + InboundListItemModel.model_validate({**record.model_dump(), **summaries.get(record.inbound_id, {})}) + for record in records + ] if isinstance(result, PageModel): return result.model_copy(update={'rows': models}) return models @@ -284,7 +298,31 @@ class InboundService: ) if inbound is None: cls._raise_inbound_not_accessible(current_user) - return InboundModel.model_validate(inbound) + model = InboundModel.model_validate(inbound) + if not model.items: + return model + # 托盘编号与当前状态只读展示,不参与任何写入或校验 + identities = await InboundSummaryDao.get_item_identities(query_db, model.inbound_id, test_scope) + return model.model_copy( + update={ + 'items': [ + InboundItemModel.model_validate({**item.model_dump(), **identities.get(item.inbound_item_id, {})}) + for item in model.items + ] + } + ) + + @classmethod + async def get_inbound_overview_services( + cls, + query_db: AsyncSession, + current_user: CurrentUserModel, + test_scope: TestBatchScope, + ) -> InboundOverviewModel: + warehouse_ids = await cls._warehouse_scope(query_db, current_user) + month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + overview = await InboundSummaryDao.get_overview(query_db, warehouse_ids, test_scope, month_start) + return InboundOverviewModel.model_validate(overview) @classmethod async def get_warehouse_options_services( diff --git a/shierfeng-fastapi-backend/module_business/service/master_excel_service.py b/shierfeng-fastapi-backend/module_business/service/master_excel_service.py index 023fb67..39ae177 100644 --- a/shierfeng-fastapi-backend/module_business/service/master_excel_service.py +++ b/shierfeng-fastapi-backend/module_business/service/master_excel_service.py @@ -15,6 +15,7 @@ from exceptions.exception import PermissionException, ServiceException from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.master_dao import MasterDao from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import MasterImportPreviewModel, MasterImportPreviewRowModel from module_business.entity.vo.master_vo import ( CustomerModel, LineModel, @@ -33,6 +34,11 @@ _MAX_IMPORT_BYTES = 5 * 1024 * 1024 _MAX_IMPORT_ROWS = 5000 _STATUS_TO_VALUE = {'正常': '0', '停用': '1'} _VALUE_TO_STATUS = {'0': '正常', '1': '停用'} +_PREVIEW_MESSAGES: dict[ImportOutcome, str] = { + 'add': '校验通过,将新增', + 'update': '{code}已存在,将按文件内容更新', + 'skip': '{code}已存在,保持原资料不变', +} @dataclass(frozen=True) @@ -245,6 +251,65 @@ class MasterExcelService: raise ServiceException(message='导入失败,数据已全部回滚') from exc return CrudResponseModel(is_success=True, message=cls._summary(added, updated, skipped, details)) + @classmethod + async def preview_import( + cls, + db: AsyncSession, + resource: MasterExcelResource, + file: UploadFile, + update_support: bool, + current_user: CurrentUserModel, + scope: TestBatchScope, + ) -> MasterImportPreviewModel: + """走与正式导入完全相同的逐行校验,最后整体回滚,返回每行将发生的结果。""" + spec = _SPECS[resource] + parsed_rows = await cls._read_upload(resource, file) + rows: list[MasterImportPreviewRowModel] = [] + try: + if resource == 'pallet': + await PalletService._prepare_test_write(db, current_user, scope) + else: + await MasterService._prepare_test_write(db, current_user, scope) + for row in parsed_rows: + values = [row.values.get(header, '') for header in spec.headers] + try: + outcome, _ = await cls._apply_row(db, resource, row, update_support, current_user, scope) + except ServiceException as exc: + rows.append( + MasterImportPreviewRowModel( + excel_row=row.excel_row, + outcome='error', + message=exc.message or '校验失败', + values=values, + ) + ) + continue + rows.append( + MasterImportPreviewRowModel( + excel_row=row.excel_row, + outcome=outcome, + message=_PREVIEW_MESSAGES[outcome].format(code=spec.code_header), + values=values, + ) + ) + except Exception as exc: + if isinstance(exc, (ServiceException, PermissionException)): + raise + raise ServiceException(message='导入校验失败,请稍后重试') from exc + finally: + await db.rollback() + counts = {outcome: sum(1 for row in rows if row.outcome == outcome) for outcome in _PREVIEW_MESSAGES} + return MasterImportPreviewModel( + headers=list(spec.headers), + update_support=update_support, + total_rows=len(rows), + add_count=counts['add'], + update_count=counts['update'], + skip_count=counts['skip'], + error_count=sum(1 for row in rows if row.outcome == 'error'), + rows=rows, + ) + @classmethod async def _read_upload(cls, resource: MasterExcelResource, file: UploadFile) -> list[ParsedRow]: spec = _SPECS[resource] diff --git a/shierfeng-fastapi-backend/module_business/service/master_summary_service.py b/shierfeng-fastapi-backend/module_business/service/master_summary_service.py new file mode 100644 index 0000000..778f2f3 --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/service/master_summary_service.py @@ -0,0 +1,89 @@ +from collections.abc import Awaitable, Callable +from typing import Any, Literal + +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_business.dao.master_summary_dao import MasterSummaryDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import ( + CustomerDetailModel, + CustomerListItemModel, + LineListItemModel, + MasterParentOptionModel, + PalletDetailModel, + PalletListItemModel, + SiteListItemModel, + StrainListItemModel, + TunnelListItemModel, + WarehouseListItemModel, +) +from module_business.entity.vo.master_vo import CustomerModel, PalletModel + +SummaryResource = Literal['strain', 'customer', 'site', 'line', 'tunnel', 'warehouse', 'pallet'] +_SummaryLoader = Callable[[AsyncSession, list[int], TestBatchScope], Awaitable[dict[int, dict[str, Any]]]] + +_ENRICHERS: dict[SummaryResource, tuple[str, _SummaryLoader, type[BaseModel]]] = { + 'strain': ('strain_id', MasterSummaryDao.get_strain_summaries, StrainListItemModel), + 'customer': ('customer_id', MasterSummaryDao.get_customer_summaries, CustomerListItemModel), + 'site': ('site_id', MasterSummaryDao.get_site_summaries, SiteListItemModel), + 'line': ('line_id', MasterSummaryDao.get_line_summaries, LineListItemModel), + 'tunnel': ('tunnel_id', MasterSummaryDao.get_tunnel_summaries, TunnelListItemModel), + 'warehouse': ('warehouse_id', MasterSummaryDao.get_warehouse_summaries, WarehouseListItemModel), + 'pallet': ('pallet_id', MasterSummaryDao.get_pallet_summaries, PalletListItemModel), +} + + +class MasterSummaryService: + """为基础资料页面补充只读汇总。导出与写入仍使用原主数据模型。""" + + @classmethod + async def enrich_page( + cls, + db: AsyncSession, + resource: SummaryResource, + page: PageModel, + scope: TestBatchScope, + ) -> PageModel: + id_field, loader, item_model = _ENRICHERS[resource] + rows = list(page.rows) + summaries = await loader(db, [getattr(row, id_field) for row in rows], scope) + enriched = [ + item_model.model_validate({**row.model_dump(), **summaries.get(getattr(row, id_field), {})}) + for row in rows + ] + return page.model_copy(update={'rows': enriched}) + + @classmethod + async def customer_detail( + cls, + db: AsyncSession, + customer: CustomerModel, + scope: TestBatchScope, + ) -> CustomerDetailModel: + sites = await MasterSummaryDao.get_customer_site_tree(db, customer.customer_id, scope) + return CustomerDetailModel.model_validate({**customer.model_dump(), 'sites': sites}) + + @classmethod + async def pallet_detail( + cls, + db: AsyncSession, + pallet: PalletModel, + scope: TestBatchScope, + ) -> PalletDetailModel: + summaries = await MasterSummaryDao.get_pallet_summaries(db, [pallet.pallet_id], scope) + events = await MasterSummaryDao.get_pallet_recent_events(db, pallet.pallet_id, scope) + return PalletDetailModel.model_validate( + {**pallet.model_dump(), **summaries.get(pallet.pallet_id, {}), 'recent_events': events} + ) + + @classmethod + async def customer_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[MasterParentOptionModel]: + rows = await MasterSummaryDao.list_customer_options(db, scope) + return [MasterParentOptionModel.model_validate(row) for row in rows] + + @classmethod + async def site_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[MasterParentOptionModel]: + rows = await MasterSummaryDao.list_site_options(db, scope) + return [MasterParentOptionModel.model_validate(row) for row in rows] diff --git a/shierfeng-fastapi-backend/module_business/service/return_service.py b/shierfeng-fastapi-backend/module_business/service/return_service.py index c5ac049..13c27ad 100644 --- a/shierfeng-fastapi-backend/module_business/service/return_service.py +++ b/shierfeng-fastapi-backend/module_business/service/return_service.py @@ -16,6 +16,7 @@ from module_business.dao.access_dao import AccessDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao +from module_business.dao.return_summary_dao import ReturnSummaryDao from module_business.domain.enums import PalletEventType, PalletFlowState, ReturnStatus from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope @@ -32,10 +33,14 @@ from module_business.entity.vo.operation_vo import ( BusinessOptionModel, PalletScanResponseModel, ReturnConfirmModel, + ReturnItemModel, + ReturnListItemModel, ReturnModel, + ReturnOverviewModel, ReturnPageQueryModel, ReturnRequestModel, ReturnScanQueryModel, + ReturnSiteOptionModel, ) from module_business.service.access_service import AccessService from module_business.service.test_batch_access_service import TestBatchAccessService @@ -211,7 +216,7 @@ class ReturnService: current_user: CurrentUserModel, test_scope: TestBatchScope, is_page: bool = True, - ) -> PageModel | list[ReturnModel]: + ) -> PageModel | list[ReturnListItemModel]: site_ids, warehouse_ids = await cls._scopes(query_db, current_user) if site_ids == [] and warehouse_ids == []: if is_page: @@ -233,7 +238,17 @@ class ReturnService: is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result - models = [ReturnModel.model_validate(record) for record in rows] + records = [ReturnModel.model_validate(record) for record in rows] + # 仅对本页已按数据范围筛出的单据补充名称与托盘汇总 + summaries = await ReturnSummaryDao.get_list_summaries( + query_db, + [record.return_id for record in records], + test_scope, + ) + models = [ + ReturnListItemModel.model_validate({**record.model_dump(), **summaries.get(record.return_id, {})}) + for record in records + ] if isinstance(result, PageModel): return result.model_copy(update={'rows': models}) return models @@ -257,7 +272,42 @@ class ReturnService: if return_order is None: cls._raise_return_not_accessible(current_user) assert_record_in_scope(return_order, test_scope, '退回单') - return ReturnModel.model_validate(return_order) + model = ReturnModel.model_validate(return_order) + if not model.items: + return model + # 托盘编号与隧道名称只读展示,不参与确认核对;核对仍只使用托盘ID + identities = await ReturnSummaryDao.get_item_identities(query_db, model.return_id, test_scope) + return model.model_copy( + update={ + 'items': [ + ReturnItemModel.model_validate({**item.model_dump(), **identities.get(item.return_item_id, {})}) + for item in model.items + ] + } + ) + + @classmethod + async def get_return_overview_services( + cls, + query_db: AsyncSession, + current_user: CurrentUserModel, + test_scope: TestBatchScope, + ) -> ReturnOverviewModel: + site_ids, warehouse_ids = await cls._scopes(query_db, current_user) + month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + overview = await ReturnSummaryDao.get_overview(query_db, site_ids, warehouse_ids, test_scope, month_start) + return ReturnOverviewModel.model_validate(overview) + + @classmethod + async def get_site_options_services( + cls, + query_db: AsyncSession, + current_user: CurrentUserModel, + test_scope: TestBatchScope, + ) -> list[ReturnSiteOptionModel]: + site_ids, warehouse_ids = await cls._scopes(query_db, current_user) + options = await ReturnSummaryDao.get_site_options(query_db, site_ids, warehouse_ids, test_scope) + return [ReturnSiteOptionModel.model_validate(option) for option in options] @classmethod async def get_warehouse_options_services( diff --git a/shierfeng-fastapi-backend/module_business/service/shipment_service.py b/shierfeng-fastapi-backend/module_business/service/shipment_service.py index 08d8575..e2bc329 100644 --- a/shierfeng-fastapi-backend/module_business/service/shipment_service.py +++ b/shierfeng-fastapi-backend/module_business/service/shipment_service.py @@ -14,6 +14,8 @@ from module_business.dao.access_dao import AccessDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao +from module_business.dao.shipment_label_dao import ShipmentLabelDao +from module_business.dao.shipment_summary_dao import ShipmentSummaryDao from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope @@ -31,6 +33,7 @@ from module_business.entity.vo.operation_vo import ( ConfirmCommandModel, PalletScanResponseModel, ShipmentCreateModel, + ShipmentListItemModel, ShipmentModel, ShipmentPageQueryModel, ShipmentScanQueryModel, @@ -254,7 +257,7 @@ class ShipmentService: current_user: CurrentUserModel, test_scope: TestBatchScope, is_page: bool = True, - ) -> PageModel | list[ShipmentModel]: + ) -> PageModel | list[ShipmentListItemModel]: warehouse_ids, site_ids = await cls._scopes(query_db, current_user) if ( warehouse_ids is not None @@ -288,7 +291,17 @@ class ShipmentService: is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result - models = [ShipmentModel.model_validate(record) for record in rows] + records = [ShipmentModel.model_validate(record) for record in rows] + # 仅对本页已按数据范围筛出的单据补充名称与汇总 + summaries = await ShipmentSummaryDao.get_list_summaries( + query_db, + [record.shipment_id for record in records], + test_scope, + ) + models = [ + ShipmentListItemModel.model_validate({**record.model_dump(), **summaries.get(record.shipment_id, {})}) + for record in records + ] if isinstance(result, PageModel): return result.model_copy(update={'rows': models}) return models @@ -311,7 +324,35 @@ class ShipmentService: ) if shipment is None: cls._raise_shipment_not_accessible(current_user) - return ShipmentModel.model_validate(shipment) + model = ShipmentModel.model_validate(shipment) + identities = await cls._item_identities(query_db, shipment, test_scope) + if not identities: + return model + return model.model_copy( + update={ + 'items': [ + item.model_copy(update=identities.get(item.shipment_item_id, {})) + for item in model.items + ] + } + ) + + @staticmethod + async def _item_identities( + query_db: AsyncSession, + shipment: BizShipmentOrder, + test_scope: TestBatchScope, + ) -> dict[int, dict[str, str | None]]: + """为详情明细补充托盘编号与菌种批次,只读展示,不参与任何写入或数量计算。""" + identities: dict[int, dict[str, str | None]] = {} + for item, pallet, filling, _quantity in await ShipmentLabelDao.get_label_sources(query_db, shipment, test_scope): + identities[item.shipment_item_id] = { + 'pallet_code': getattr(pallet, 'pallet_code', None), + 'strain_code': getattr(filling, 'strain_code_snapshot', None), + 'strain_name': getattr(filling, 'strain_name_snapshot', None), + 'batch_no': getattr(filling, 'batch_no', None), + } + return identities @classmethod async def get_warehouse_options_services( diff --git a/shierfeng-fastapi-backend/module_business/service/usage_fifo_service.py b/shierfeng-fastapi-backend/module_business/service/usage_fifo_service.py index 86b1e8a..b4beb0f 100644 --- a/shierfeng-fastapi-backend/module_business/service/usage_fifo_service.py +++ b/shierfeng-fastapi-backend/module_business/service/usage_fifo_service.py @@ -6,7 +6,8 @@ from module_business.dao.master_dao import MasterDao from module_business.dao.usage_fifo_dao import UsageFifoDao from module_business.domain.fifo_policy import requires_fifo_skip_reason from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope -from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel +from module_business.entity.vo.usage_fifo_vo import UsageFifoQueueModel, UsageFifoRecommendationModel +from module_business.service.access_service import AccessService from module_business.service.usage_service import UsageService @@ -52,3 +53,19 @@ class UsageFifoService: recommended=candidate, unknown_arrival_count=unknown, requires_skip_reason=needs_reason, message=message, ) + + @staticmethod + async def queue( + db: AsyncSession, + site_id: int, + user: CurrentUserModel, + scope: TestBatchScope, + ) -> UsageFifoQueueModel: + """现场待使用队列(只读):与建议共用候选条件和排序,仅包含尚未入隧的托盘。""" + await AccessService.assert_site_access(db, user, site_id) + site = await MasterDao.get_site(db, site_id, scope) + if site is None: + raise ServiceException(message='客户现场不存在') + assert_record_in_scope(site, scope, '现场') + strains = await UsageFifoDao.queue(db, site_id=site_id, scope=scope) + return UsageFifoQueueModel.model_validate({'site_id': site_id, 'strains': strains}) diff --git a/shierfeng-fastapi-backend/module_business/service/usage_service.py b/shierfeng-fastapi-backend/module_business/service/usage_service.py index 41ad815..a6c10b1 100644 --- a/shierfeng-fastapi-backend/module_business/service/usage_service.py +++ b/shierfeng-fastapi-backend/module_business/service/usage_service.py @@ -3,7 +3,7 @@ import hashlib import json import secrets -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, NoReturn from sqlalchemy.exc import IntegrityError @@ -17,6 +17,7 @@ from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao from module_business.dao.usage_fifo_dao import UsageFifoDao +from module_business.dao.usage_summary_dao import UsageSummaryDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.fifo_policy import requires_fifo_skip_reason from module_business.domain.pallet_state_machine import PalletStateMachine @@ -29,8 +30,11 @@ from module_business.entity.vo.operation_vo import ( TunnelEntryCreateModel, TunnelEntrySuccessResponseModel, UsageCreateModel, + UsageListItemModel, UsageModel, UsagePageQueryModel, + UsageSiteBoardModel, + UsageSiteOptionModel, UsageSuccessResponseModel, ) from module_business.service.access_service import AccessService @@ -279,7 +283,7 @@ class UsageService: current_user: CurrentUserModel, test_scope: TestBatchScope, is_page: bool = True, - ) -> PageModel | list[UsageModel]: + ) -> PageModel | list[UsageListItemModel]: site_ids = await cls._site_scope(query_db, current_user) if site_ids is not None and query_object.site_id is not None and query_object.site_id not in site_ids: raise PermissionException(data='', message='无权访问该客户现场') @@ -301,11 +305,56 @@ class UsageService: is_page=is_page, ) rows = result.rows if isinstance(result, PageModel) else result - models = [UsageModel.model_validate(record) for record in rows] + records = [UsageModel.model_validate(record) for record in rows] + # 仅对本页已按数据范围筛出的记录补充名称;不改变记录本身 + summaries = await UsageSummaryDao.get_list_summaries( + query_db, + [record.usage_id for record in records], + test_scope, + ) + models = [ + UsageListItemModel.model_validate({**record.model_dump(), **summaries.get(record.usage_id, {})}) + for record in records + ] if isinstance(result, PageModel): return result.model_copy(update={'rows': models}) return models + @classmethod + async def get_site_options_services( + cls, + query_db: AsyncSession, + current_user: CurrentUserModel, + test_scope: TestBatchScope, + ) -> list[UsageSiteOptionModel]: + site_ids = await cls._site_scope(query_db, current_user) + options = await UsageSummaryDao.get_site_options(query_db, site_ids, test_scope) + return [UsageSiteOptionModel.model_validate(option) for option in options] + + @classmethod + async def get_site_board_services( + cls, + query_db: AsyncSession, + site_id: int, + current_user: CurrentUserModel, + test_scope: TestBatchScope, + ) -> UsageSiteBoardModel: + cls._current_identity(current_user) + await AccessService.assert_site_access(query_db, current_user, site_id) + site = await MasterDao.get_site(query_db, site_id, test_scope) + if site is None: + raise ServiceException(message='客户现场不存在') + assert_record_in_scope(site, test_scope, '现场') + today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + board = await UsageSummaryDao.get_site_board( + query_db, + site, + test_scope, + today_start, + today_start - timedelta(days=6), + ) + return UsageSiteBoardModel.model_validate(board) + @classmethod async def usage_detail_services( cls, diff --git a/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py index a079468..c5cb886 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py @@ -230,7 +230,11 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]: ('/master/warehouse/{warehouse_id}/users', 'GET'): 'master:warehouse:assign', ('/master/warehouse/{warehouse_id}/users', 'PUT'): 'master:warehouse:assign', ('/master/pallet/{pallet_id}/qrcode', 'GET'): 'master:pallet:print', + ('/master/site/customer-options', 'GET'): 'master:site:list', + ('/master/line/site-options', 'GET'): 'master:line:list', + ('/master/tunnel/site-options', 'GET'): 'master:tunnel:list', ('/business/inbound/list', 'GET'): 'business:inbound:list', + ('/business/inbound/summary', 'GET'): 'business:inbound:list', ('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query', ('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query', ('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query', @@ -239,6 +243,9 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]: ('/business/inbound/{inbound_ids}', 'DELETE'): 'business:inbound:list', ('/business/inbound/{inbound_id}/confirm', 'PUT'): 'business:inbound:confirm', ('/business/filling/list', 'GET'): 'business:filling:list', + ('/business/filling/summary', 'GET'): 'business:filling:list', + ('/business/filling/batches', 'GET'): 'business:filling:list', + ('/business/filling/batches/detail', 'GET'): 'business:filling:query', ('/business/filling/strain-options', 'GET'): 'business:filling:query', ('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query', ('/business/filling/{filling_id}', 'GET'): 'business:filling:query', @@ -254,6 +261,7 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]: ('/business/shipment-receipt/{shipment_id}/scan/{identifier}', 'GET'): 'business:shipment:receive', ('/business/shipment-receipt/{shipment_id}/confirm', 'PUT'): 'business:shipment:receive', ('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query', + ('/business/usage-fifo/queue', 'GET'): 'business:usage:query', ('/business/shipment', 'POST'): 'business:shipment:add', ('/business/shipment', 'PUT'): 'business:shipment:edit', ('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list', @@ -261,11 +269,15 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]: ('/business/usage/list', 'GET'): 'business:usage:list', ('/business/usage/line-options', 'GET'): 'business:usage:query', ('/business/usage/tunnel-options', 'GET'): 'business:usage:query', + ('/business/usage/site-options', 'GET'): 'business:usage:list', + ('/business/usage/site-board', 'GET'): 'business:usage:query', ('/business/usage/enter-tunnel', 'POST'): 'business:usage:add', ('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query', ('/business/usage/{usage_id}', 'GET'): 'business:usage:query', ('/business/usage', 'POST'): 'business:usage:add', ('/business/return/list', 'GET'): 'business:return:list', + ('/business/return/summary', 'GET'): 'business:return:list', + ('/business/return/site-options', 'GET'): 'business:return:list', ('/business/return/warehouse-options', 'GET'): 'business:return:query', ('/business/return/scan/{qr_token}', 'GET'): 'business:return:query', ('/business/return/{return_id}', 'GET'): 'business:return:query', @@ -295,11 +307,18 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]: EXPECTED_ROUTE_PERMISSIONS = _build_expected_route_permissions() STATIC_READS_BEFORE_DETAIL = { + '/master/site/{site_id}': ('/master/site/customer-options',), + '/master/line/{line_id}': ('/master/line/site-options',), + '/master/tunnel/{tunnel_id}': ('/master/tunnel/site-options',), '/business/inbound/{inbound_id}': ( + '/business/inbound/summary', '/business/inbound/warehouse-options', '/business/inbound/scan/{qr_token}', ), '/business/filling/{filling_id}': ( + '/business/filling/summary', + '/business/filling/batches', + '/business/filling/batches/detail', '/business/filling/strain-options', '/business/filling/scan/{qr_token}', ), @@ -311,9 +330,13 @@ STATIC_READS_BEFORE_DETAIL = { '/business/usage/{usage_id}': ( '/business/usage/line-options', '/business/usage/tunnel-options', + '/business/usage/site-options', + '/business/usage/site-board', '/business/usage/scan/{qr_token}', ), '/business/return/{return_id}': ( + '/business/return/summary', + '/business/return/site-options', '/business/return/warehouse-options', '/business/return/scan/{qr_token}', ), @@ -327,6 +350,8 @@ EXACT_QUERY_PARAMETERS = { ('/business/shipment/scan/{qr_token}', 'GET'): {'sourceWarehouseId', 'targetSiteId'}, ('/business/usage/line-options', 'GET'): {'siteId'}, ('/business/usage/tunnel-options', 'GET'): {'siteId'}, + ('/business/usage/site-board', 'GET'): {'siteId'}, + ('/business/usage-fifo/queue', 'GET'): {'siteId'}, ('/business/return/scan/{qr_token}', 'GET'): {'mode', 'returnId'}, } REQUIRED_QUERY_PARAMETERS = { @@ -349,7 +374,7 @@ def _auth_dependencies(route: APIRoute) -> list[CheckUserInterfaceAuth]: def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None: expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values()) - test_case.assertEqual(119, expected.total()) + test_case.assertEqual(131, expected.total()) test_case.assertEqual(expected, Counter(literals)) test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected)) @@ -501,7 +526,7 @@ class ApiPermissionContractTest(unittest.TestCase): } self.assertEqual(set(EXPECTED_ROUTE_PERMISSIONS) | UNGUARDED_AUTHENTICATED_ROUTES, actual_operations) - self.assertEqual(121, len(actual_operations)) + self.assertEqual(133, len(actual_operations)) openapi_ai_paths = sorted(path for path in self.schema['paths'] if path == '/ai' or path.startswith('/ai/')) app_ai_paths = sorted( @@ -582,7 +607,7 @@ class ApiPermissionContractTest(unittest.TestCase): def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None: literals = list(EXPECTED_ROUTE_PERMISSIONS.values()) - self.assertEqual(119, len(literals)) + self.assertEqual(131, len(literals)) literals.append(literals[0]) with self.assertRaises(AssertionError): diff --git a/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py b/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py index d1c2caf..f045b0f 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_filling_service.py @@ -21,6 +21,7 @@ from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel from module_admin.service.login_service import LoginService from module_business.controller import filling_controller as filling_controller_module from module_business.dao.access_dao import AccessDao +from module_business.dao.filling_summary_dao import FillingSummaryDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao @@ -30,8 +31,12 @@ from module_business.entity.do.master_do import BizPallet, BizStrain from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory from module_business.entity.vo.operation_vo import ( BusinessOptionModel, + FillingBatchDetailModel, + FillingBatchModel, FillingCreateModel, + FillingListItemModel, FillingModel, + FillingOverviewModel, FillingPageQueryModel, PalletScanResponseModel, ) @@ -242,6 +247,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase): with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[9, 7, 7])), patch.object(OperationDao, 'get_filling_list', new=get_list), + patch.object(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})), ): result = await FillingService.get_filling_list_services(self.db, query, self.user, TestBatchScope.formal()) @@ -1169,6 +1175,9 @@ class TestFillingController(unittest.TestCase): schema = app.openapi() expected = { ('/business/filling/list', 'get'), + ('/business/filling/summary', 'get'), + ('/business/filling/batches', 'get'), + ('/business/filling/batches/detail', 'get'), ('/business/filling/strain-options', 'get'), ('/business/filling/scan/{qr_token}', 'get'), ('/business/filling/{filling_id}', 'get'), @@ -1179,7 +1188,21 @@ class TestFillingController(unittest.TestCase): self.assertEqual({'qr_token'}, {parameter['name'] for parameter in scan_parameters}) routes = self._routes() - self.assertEqual(PageResponseModel[FillingModel], routes[('/business/filling/list', 'GET')].response_model) + self.assertEqual( + PageResponseModel[FillingListItemModel], routes[('/business/filling/list', 'GET')].response_model + ) + self.assertEqual( + DataResponseModel[FillingOverviewModel], + routes[('/business/filling/summary', 'GET')].response_model, + ) + self.assertEqual( + PageResponseModel[FillingBatchModel], + routes[('/business/filling/batches', 'GET')].response_model, + ) + self.assertEqual( + DataResponseModel[FillingBatchDetailModel], + routes[('/business/filling/batches/detail', 'GET')].response_model, + ) self.assertEqual( DataResponseModel[list[BusinessOptionModel]], routes[('/business/filling/strain-options', 'GET')].response_model, @@ -1197,12 +1220,21 @@ class TestFillingController(unittest.TestCase): def test_static_routes_are_declared_before_dynamic_detail(self) -> None: paths = [route.path_format for route in filling_controller_module.filling_controller.routes] detail_index = paths.index('/business/filling/{filling_id}') - self.assertLess(paths.index('/business/filling/strain-options'), detail_index) - self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index) + for static_path in ( + '/business/filling/summary', + '/business/filling/batches', + '/business/filling/batches/detail', + '/business/filling/strain-options', + '/business/filling/scan/{qr_token}', + ): + self.assertLess(paths.index(static_path), detail_index) def test_each_route_has_exact_permission_and_three_unique_values(self) -> None: expected = { ('/business/filling/list', 'GET'): 'business:filling:list', + ('/business/filling/summary', 'GET'): 'business:filling:list', + ('/business/filling/batches', 'GET'): 'business:filling:list', + ('/business/filling/batches/detail', 'GET'): 'business:filling:query', ('/business/filling/strain-options', 'GET'): 'business:filling:query', ('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query', ('/business/filling/{filling_id}', 'GET'): 'business:filling:query', diff --git a/shierfeng-fastapi-backend/tests/module_business/test_filling_summary.py b/shierfeng-fastapi-backend/tests/module_business/test_filling_summary.py new file mode 100644 index 0000000..aeaca90 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/module_business/test_filling_summary.py @@ -0,0 +1,431 @@ +import unittest +from datetime import date, datetime, timedelta +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from sqlalchemy.dialects import mysql +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from exceptions.exception import PermissionException, ServiceException +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dao.access_dao import AccessDao +from module_business.dao.filling_summary_dao import FillingSummaryDao +from module_business.dao.operation_dao import OperationDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.operation_vo import ( + FillingBatchDetailModel, + FillingBatchDetailQueryModel, + FillingBatchModel, + FillingBatchPageQueryModel, + FillingListItemModel, + FillingOverviewModel, + FillingPageQueryModel, +) +from module_business.service.filling_service import FillingService +from utils.page_util import PageUtil + + +def compile_sql(statement: Any) -> str: + compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}) + return ' '.join(str(compiled).split()).replace('%%', '%') + + +def make_test_scope(batch_id: int) -> TestBatchScope: + return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True) + + +def result_rows(rows: list[Any]) -> MagicMock: + result = MagicMock() + result.all.return_value = rows + result.first.return_value = rows[0] if rows else None + return result + + +def result_scalar(value: Any) -> MagicMock: + result = MagicMock() + result.scalar_one.return_value = value + return result + + +def batch_row(**overrides: Any) -> SimpleNamespace: + values = { + 'strain_id': 3, + 'batch_no': 'B261017-01', + 'strain_code': 'A15', + 'strain_name': '平菇 A15', + 'production_date': date(2026, 10, 16), + 'expiry_date': date(2026, 11, 30), + 'pallet_count': 6, + 'package_sum': 600, + 'last_operated_at': datetime(2026, 10, 17, 9, 10), + 'last_filling_id': 88, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def admin_user() -> CurrentUserModel: + return CurrentUserModel.model_construct( + permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True) + ) + + +def normal_user() -> CurrentUserModel: + return CurrentUserModel.model_construct( + permissions=['business:filling:list'], + roles=['common'], + user=SimpleNamespace(user_id=5, user_name='ln', admin=False), + ) + + +class FillingListFilterTest(unittest.IsolatedAsyncioTestCase): + async def _capture(self, query: FillingPageQueryModel) -> str: + with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate: + await OperationDao.get_filling_list( + MagicMock(spec=AsyncSession), + query, + warehouse_ids=[7], + scope=make_test_scope(4), + ) + return compile_sql(paginate.await_args.args[1]) + + async def test_keyword_matches_filling_no_pallet_code_or_batch_within_scope(self) -> None: + sql = await self._capture(FillingPageQueryModel(keyword='B2610')) + for fragment in ( + 'JOIN biz_pallet ON biz_pallet.pallet_id = biz_filling_record.pallet_id', + "biz_filling_record.filling_no LIKE '%B2610%'", + "biz_pallet.pallet_code LIKE '%B2610%'", + "biz_filling_record.batch_no LIKE '%B2610%'", + 'biz_filling_record.test_batch_id = 4', + 'biz_filling_record.warehouse_id IN (7)', + ): + self.assertIn(fragment, sql) + + async def test_without_keyword_or_pallet_code_list_does_not_join_pallets(self) -> None: + sql = await self._capture(FillingPageQueryModel(batchNo='B1')) + self.assertNotIn('JOIN', sql) + + def test_keyword_rejects_blank_value(self) -> None: + for model in (FillingPageQueryModel, FillingBatchPageQueryModel): + with self.subTest(model=model.__name__), self.assertRaises(ValueError): + model(keyword=' ') + + +class FillingListSummaryDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_empty_ids_skip_database(self) -> None: + db = MagicMock(spec=AsyncSession) + self.assertEqual({}, await FillingSummaryDao.get_list_summaries(db, [], TestBatchScope.formal())) + db.execute.assert_not_called() + + async def test_names_are_joined_in_same_batch_scope(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10401', '成品一仓', '年糕')])) + + summaries = await FillingSummaryDao.get_list_summaries(db, [32, 31, 31], make_test_scope(4)) + + self.assertEqual( + {'pallet_code': 'TP-10401', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}, summaries[31] + ) + self.assertEqual({'pallet_code': None, 'warehouse_name': None, 'operator_name': None}, summaries[32]) + sql = compile_sql(db.execute.await_args.args[0]) + for fragment in ( + 'biz_filling_record.filling_id IN (31, 32)', + 'biz_filling_record.test_batch_id = 4', + 'biz_pallet.test_batch_id = 4', + 'biz_warehouse.test_batch_id = 4', + 'sys_user.user_id = biz_filling_record.operator_id', + "sys_user.del_flag = '0'", + ): + self.assertIn(fragment, sql) + + +class FillingBatchPageDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_groups_by_strain_and_batch_newest_first_within_scope(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock( + side_effect=[ + result_scalar(18), + result_rows([batch_row(), batch_row(batch_no='B261016-01', last_filling_id=80)]), + result_rows([(88, '1kg/包', '年糕'), (80, '1.2kg/包', None)]), + ] + ) + query = FillingBatchPageQueryModel( + pageNum=2, pageSize=5, keyword='A15', strainId=3, beginTime='2026-10-01', endTime='2026-10-17' + ) + + batches, total = await FillingSummaryDao.get_batch_page(db, query, [7, 6], make_test_scope(4)) + + self.assertEqual(18, total) + self.assertEqual(['B261017-01', 'B261016-01'], [batch['batch_no'] for batch in batches]) + self.assertEqual(('1kg/包', '年糕'), (batches[0]['package_spec'], batches[0]['last_operator_name'])) + self.assertEqual(('1.2kg/包', None), (batches[1]['package_spec'], batches[1]['last_operator_name'])) + FillingBatchModel.model_validate(batches[0]) + + count_sql, page_sql, latest_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list) + for sql in (count_sql, page_sql): + for fragment in ( + 'GROUP BY biz_filling_record.strain_id, biz_filling_record.batch_no', + 'biz_filling_record.test_batch_id = 4', + 'biz_filling_record.warehouse_id IN (6, 7)', + "biz_filling_record.batch_no LIKE '%A15%'", + "biz_filling_record.strain_name_snapshot LIKE '%A15%'", + 'biz_filling_record.strain_id = 3', + "biz_filling_record.operated_at >= '2026-10-01 00:00:00'", + "biz_filling_record.operated_at < '2026-10-18 00:00:00'", + ): + self.assertIn(fragment, sql) + self.assertIn( + 'ORDER BY max(biz_filling_record.operated_at) DESC, max(biz_filling_record.filling_id) DESC', page_sql + ) + self.assertIn('LIMIT 5, 5', page_sql) + self.assertIn('biz_filling_record.filling_id IN (80, 88)', latest_sql) + + async def test_empty_page_skips_latest_record_lookup(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(side_effect=[result_scalar(0), result_rows([])]) + + batches, total = await FillingSummaryDao.get_batch_page( + db, FillingBatchPageQueryModel(), [], TestBatchScope.formal() + ) + + self.assertEqual(([], 0), (batches, total)) + self.assertEqual(2, db.execute.await_count) + self.assertIn('false', compile_sql(db.execute.await_args_list[0].args[0]).lower()) + + +class FillingBatchDetailDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_missing_batch_returns_none_without_further_queries(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([])) + + self.assertIsNone(await FillingSummaryDao.get_batch_detail(db, 3, 'B1', [7], TestBatchScope.formal())) + self.assertEqual(1, db.execute.await_count) + + async def test_distribution_uses_active_cycle_and_closed_cycles_count_as_consumed(self) -> None: + db = MagicMock(spec=AsyncSession) + recent = [ + (90, 'FL-90', 501, 'TP-10501', '成品一仓', '李娜', datetime(2026, 10, 17, 9, 10), '1kg/包'), + (89, 'FL-89', 500, None, None, None, datetime(2026, 10, 17, 9, 5), '0.8kg/包'), + ] + db.execute = AsyncMock( + side_effect=[ + result_rows([batch_row(pallet_count=30)]), + result_rows([('AT_SITE_UNUSED', 15), (None, 2), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5)]), + result_rows(recent), + ] + ) + + detail = await FillingSummaryDao.get_batch_detail(db, 3, 'B261017-01', [7], make_test_scope(4)) + + self.assertEqual( + [('AT_SITE_UNUSED', 15), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5), ('CONSUMED', 2)], + [(item['state'], item['pallet_count']) for item in detail['distribution']], + ) + self.assertEqual(['TP-10501', None], [item['pallet_code'] for item in detail['recent']]) + # 包装规格与操作人取批次内最新一条记录 + self.assertEqual(('1kg/包', '李娜'), (detail['package_spec'], detail['last_operator_name'])) + FillingBatchDetailModel.model_validate(detail) + + header_sql, distribution_sql, recent_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list) + for sql in (header_sql, distribution_sql, recent_sql): + for fragment in ( + 'biz_filling_record.strain_id = 3', + "biz_filling_record.batch_no = 'B261017-01'", + 'biz_filling_record.warehouse_id IN (7)', + 'biz_filling_record.test_batch_id = 4', + ): + self.assertIn(fragment, sql) + self.assertIn('biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id', distribution_sql) + self.assertIn('biz_pallet_inventory.test_batch_id = 4', distribution_sql) + self.assertIn('ORDER BY biz_filling_record.operated_at DESC, biz_filling_record.filling_id DESC', recent_sql) + self.assertIn('LIMIT 10', recent_sql) + + +class FillingOverviewDaoTest(unittest.IsolatedAsyncioTestCase): + def _db(self) -> MagicMock: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock( + side_effect=[ + result_rows([(3, 'B261017-01', '平菇 A15', 'A15', 6, 600)]), + result_rows( + [ + (3, 'B261017-01', '平菇 A15', 'A15', 6, 600), + (3, 'B261016-01', '平菇 A15', 'A15', 10, 1000), + (4, 'B261015-01', '白灵菇 B22', 'B22', 27, 2700), + (5, 'B261010-01', None, 'S130', 18, 1800), + (6, 'B261008-01', '金针菇 J61', 'J61', 8, 800), + ] + ), + result_rows([(6, '周转仓', 10), (4, '成品一仓', 13)]), + result_rows([('B260930-01', date(2026, 10, 28), 3), ('B260923-01', date(2026, 10, 25), 4)]), + ] + ) + return db + + async def test_overview_totals_top_strains_empty_pallets_and_nearest_expiry(self) -> None: + db = self._db() + + overview = await FillingSummaryDao.get_overview( + db, + [4, 6], + make_test_scope(4), + datetime(2026, 10, 17), + datetime(2026, 10, 1), + date(2026, 10, 31), + 14, + ) + + self.assertEqual( + (6, 600, 1), (overview['today_pallet_count'], overview['today_package_sum'], overview['today_batch_count']) + ) + self.assertEqual((69, 5), (overview['month_pallet_count'], overview['month_batch_count'])) + self.assertEqual( + [('白灵菇 B22', 27), ('S130', 18), ('平菇 A15', 16)], + [(item['strain_name'], item['pallet_count']) for item in overview['month_top_strains']], + ) + self.assertEqual(23, overview['empty_pallet_count']) + self.assertEqual([4, 6], [row['warehouse_id'] for row in overview['empty_by_warehouse']]) + self.assertEqual((7, 2), (overview['near_expiry_pallet_count'], overview['near_expiry_batch_count'])) + self.assertEqual( + ('B260923-01', date(2026, 10, 25)), (overview['nearest_expiry_batch_no'], overview['nearest_expiry_date']) + ) + FillingOverviewModel.model_validate(overview) + + today_sql, month_sql, empty_sql, near_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list) + self.assertIn("biz_filling_record.operated_at >= '2026-10-17 00:00:00'", today_sql) + self.assertIn("biz_filling_record.operated_at >= '2026-10-01 00:00:00'", month_sql) + for sql in (today_sql, month_sql): + self.assertIn('biz_filling_record.warehouse_id IN (4, 6)', sql) + self.assertIn('biz_filling_record.test_batch_id = 4', sql) + self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql) + self.assertIn("biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'", near_sql) + self.assertIn("biz_filling_record.expiry_date <= '2026-10-31'", near_sql) + self.assertIn('biz_filling_record.filling_id = biz_pallet_inventory.active_filling_id', near_sql) + for sql in (empty_sql, near_sql): + self.assertIn('biz_pallet_inventory.warehouse_id IN (4, 6)', sql) + self.assertIn('biz_pallet_inventory.test_batch_id = 4', sql) + + async def test_user_without_warehouses_sees_nothing(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(side_effect=[result_rows([]), result_rows([]), result_rows([]), result_rows([])]) + + overview = await FillingSummaryDao.get_overview( + db, [], TestBatchScope.formal(), datetime(2026, 10, 17), datetime(2026, 10, 1), date(2026, 10, 31), 14 + ) + + self.assertEqual(0, overview['month_pallet_count']) + self.assertIsNone(overview['nearest_expiry_date']) + FillingOverviewModel.model_validate(overview) + for call in db.execute.await_args_list: + self.assertIn('false', compile_sql(call.args[0]).lower()) + + +class FillingServiceSummaryTest(unittest.IsolatedAsyncioTestCase): + async def test_list_rows_carry_names_in_camel_case(self) -> None: + record = { + 'filling_id': 31, + 'filling_no': 'FL-1', + 'pallet_id': 501, + 'warehouse_id': 4, + 'strain_id': 3, + 'strain_code_snapshot': 'A15', + 'strain_name_snapshot': '平菇 A15', + 'batch_no': 'B1', + 'package_spec': '1kg/包', + 'initial_packages': 100, + 'idempotency_key': 'k', + 'request_hash': 'h', + 'operator_id': 2, + 'operated_at': datetime(2026, 10, 17, 9, 10), + } + page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False) + summary = {31: {'pallet_code': 'TP-10501', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}} + with ( + patch.object(OperationDao, 'get_filling_list', new=AsyncMock(return_value=page)), + patch.object(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries, + ): + result = await FillingService.get_filling_list_services( + MagicMock(spec=AsyncSession), FillingPageQueryModel(), admin_user(), TestBatchScope.formal() + ) + + self.assertEqual([31], summaries.await_args.args[1]) + self.assertIsInstance(result.rows[0], FillingListItemModel) + dumped = result.rows[0].model_dump(by_alias=True) + self.assertEqual( + ('TP-10501', '成品一仓', '年糕'), (dumped['palletCode'], dumped['warehouseName'], dumped['operatorName']) + ) + + async def test_batches_page_uses_warehouse_scope_and_reports_has_next(self) -> None: + batch = {**vars(batch_row()), 'package_spec': '1kg/包', 'last_operator_name': '年糕'} + query = FillingBatchPageQueryModel(pageNum=1, pageSize=1) + with ( + patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 4, 7])), + patch.object(FillingSummaryDao, 'get_batch_page', new=AsyncMock(return_value=([batch], 3))) as get_page, + ): + result = await FillingService.get_filling_batches_services( + MagicMock(spec=AsyncSession), query, normal_user(), TestBatchScope.formal() + ) + + self.assertEqual([4, 7], get_page.await_args.args[2]) + self.assertEqual((3, True), (result.total, result.has_next)) + row = result.rows[0].model_dump(by_alias=True) + self.assertEqual(('B261017-01', 600, '年糕'), (row['batchNo'], row['packageSum'], row['lastOperatorName'])) + self.assertNotIn('lastFillingId', row) + + async def test_missing_batch_is_hidden_for_normal_user_and_precise_for_admin(self) -> None: + query = FillingBatchDetailQueryModel(strainId=3, batchNo='B1') + cases = ( + (normal_user(), PermissionException, '无权访问该生产批次'), + (admin_user(), ServiceException, '生产批次不存在'), + ) + for user, error, message in cases: + with ( + self.subTest(admin=user.user.admin), + patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])), + patch.object(FillingSummaryDao, 'get_batch_detail', new=AsyncMock(return_value=None)) as get_detail, + self.assertRaises(error) as raised, + ): + await FillingService.get_filling_batch_detail_services( + MagicMock(spec=AsyncSession), query, user, TestBatchScope.formal() + ) + self.assertEqual(message, raised.exception.message) + self.assertEqual((3, 'B1'), get_detail.await_args.args[1:3]) + + async def test_overview_passes_today_month_and_near_expiry_bounds(self) -> None: + overview = { + 'today_pallet_count': 0, + 'today_package_sum': 0, + 'today_batch_count': 0, + 'month_start': date(2026, 10, 1), + 'month_pallet_count': 0, + 'month_batch_count': 0, + 'month_top_strains': [], + 'empty_pallet_count': 0, + 'empty_by_warehouse': [], + 'near_expiry_days': 14, + 'near_expiry_pallet_count': 0, + 'near_expiry_batch_count': 0, + 'nearest_expiry_batch_no': None, + 'nearest_expiry_date': None, + } + with ( + patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])), + patch.object(FillingSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview, + ): + result = await FillingService.get_filling_overview_services( + MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal() + ) + + self.assertIsInstance(result, FillingOverviewModel) + _, warehouse_ids, _, today_start, month_start, near_until, days = get_overview.await_args.args + self.assertEqual([7], warehouse_ids) + self.assertEqual((0, 0), (today_start.hour, today_start.minute)) + self.assertEqual((1, today_start.month), (month_start.day, month_start.month)) + self.assertEqual(today_start.date() + timedelta(days=14), near_until) + self.assertEqual(14, days) + + +if __name__ == '__main__': + unittest.main() diff --git a/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py b/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py index cc71e00..0271967 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py @@ -28,6 +28,7 @@ from module_admin.service.log_service import LogQueueService from module_admin.service.login_service import LoginService from module_business.controller import inbound_controller as inbound_controller_module from module_business.dao.access_dao import AccessDao +from module_business.dao.inbound_summary_dao import InboundSummaryDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao @@ -45,7 +46,9 @@ from module_business.entity.vo.operation_vo import ( BusinessOptionModel, ConfirmCommandModel, InboundCreateModel, + InboundListItemModel, InboundModel, + InboundOverviewModel, InboundPageQueryModel, InboundScanQueryModel, InboundUpdateModel, @@ -741,6 +744,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): with ( patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 3, 7])) as scope, patch.object(OperationDao, 'get_inbound_list', new=get_list), + patch.object(InboundSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})), ): actual = await InboundService.get_inbound_list_services( self.db, query, self.user, TestBatchScope.formal(), is_page=True @@ -827,6 +831,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase): ) as scope, patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), patch.object(AccessService, 'assert_warehouse_access', new=access), + patch.object(InboundSummaryDao, 'get_item_identities', new=AsyncMock(return_value={})), ): actual = await InboundService.inbound_detail_services(self.db, 11, self.user, TestBatchScope.formal()) @@ -1900,6 +1905,7 @@ class TestInboundController(unittest.TestCase): schema = app.openapi() expected = { ('/business/inbound/list', 'get'), + ('/business/inbound/summary', 'get'), ('/business/inbound/warehouse-options', 'get'), ('/business/inbound/scan/{qr_token}', 'get'), ('/business/inbound/{inbound_id}', 'get'), @@ -1913,7 +1919,12 @@ class TestInboundController(unittest.TestCase): self.assertIn('warehouseId', {parameter['name'] for parameter in scan_parameters}) routes = self._routes() - self.assertEqual(PageResponseModel[InboundModel], routes[('/business/inbound/list', 'GET')].response_model) + self.assertEqual( + PageResponseModel[InboundListItemModel], routes[('/business/inbound/list', 'GET')].response_model + ) + self.assertEqual( + DataResponseModel[InboundOverviewModel], routes[('/business/inbound/summary', 'GET')].response_model + ) self.assertEqual( DataResponseModel[list[BusinessOptionModel]], routes[('/business/inbound/warehouse-options', 'GET')].response_model, @@ -1937,12 +1948,14 @@ class TestInboundController(unittest.TestCase): def test_static_routes_are_declared_before_dynamic_detail(self) -> None: paths = [route.path_format for route in inbound_controller_module.inbound_controller.routes] detail_index = paths.index('/business/inbound/{inbound_id}') + self.assertLess(paths.index('/business/inbound/summary'), detail_index) self.assertLess(paths.index('/business/inbound/warehouse-options'), detail_index) self.assertLess(paths.index('/business/inbound/scan/{qr_token}'), detail_index) def test_each_route_has_exact_permission_and_five_unique_permission_values(self) -> None: expected = { ('/business/inbound/list', 'GET'): 'business:inbound:list', + ('/business/inbound/summary', 'GET'): 'business:inbound:list', ('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query', ('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query', ('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query', diff --git a/shierfeng-fastapi-backend/tests/module_business/test_inbound_summary.py b/shierfeng-fastapi-backend/tests/module_business/test_inbound_summary.py new file mode 100644 index 0000000..e6fe39f --- /dev/null +++ b/shierfeng-fastapi-backend/tests/module_business/test_inbound_summary.py @@ -0,0 +1,256 @@ +import unittest +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from sqlalchemy.dialects import mysql +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dao.inbound_summary_dao import InboundSummaryDao +from module_business.dao.operation_dao import OperationDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.operation_vo import ( + InboundListItemModel, + InboundOverviewModel, + InboundPageQueryModel, +) +from module_business.service.inbound_service import InboundService + + +def compile_sql(statement: Any) -> str: + compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}) + return ' '.join(str(compiled).split()).replace('%%', '%') + + +def make_test_scope(batch_id: int) -> TestBatchScope: + return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True) + + +def result_rows(rows: list[tuple[Any, ...]]) -> MagicMock: + result = MagicMock() + result.all.return_value = rows + return result + + +def result_one(row: tuple[Any, ...]) -> MagicMock: + result = MagicMock() + result.one.return_value = row + return result + + +def result_scalar(value: Any) -> MagicMock: + result = MagicMock() + result.scalar_one.return_value = value + return result + + +def admin_user() -> CurrentUserModel: + return CurrentUserModel.model_construct( + permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True) + ) + + +class InboundListSummaryDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_empty_ids_skip_database(self) -> None: + db = MagicMock(spec=AsyncSession) + self.assertEqual({}, await InboundSummaryDao.get_list_summaries(db, [], TestBatchScope.formal())) + db.execute.assert_not_called() + + async def test_aggregates_names_and_pallet_counts_within_batch_scope(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock( + side_effect=[ + result_rows([(12, '成品一仓', '李娜', '王强'), (13, None, None, None)]), + result_rows([(12, 18)]), + ] + ) + + summaries = await InboundSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7)) + + self.assertEqual( + {'warehouse_name': '成品一仓', 'creator_name': '李娜', 'confirmer_name': '王强', 'pallet_count': 18}, + summaries[12], + ) + # 没有明细的单据托盘数为 0,名称缺失保持为空 + self.assertEqual( + {'warehouse_name': None, 'creator_name': None, 'confirmer_name': None, 'pallet_count': 0}, + summaries[13], + ) + name_sql = compile_sql(db.execute.await_args_list[0].args[0]) + count_sql = compile_sql(db.execute.await_args_list[1].args[0]) + for fragment in ( + 'biz_inbound_order.inbound_id IN (12, 13)', + 'biz_inbound_order.test_batch_id = 7', + 'biz_warehouse.test_batch_id = 7', + "creator.del_flag = '0'", + 'confirmer.user_id = biz_inbound_order.confirmed_by', + ): + self.assertIn(fragment, name_sql) + self.assertIn('biz_inbound_item.inbound_id IN (12, 13)', count_sql) + self.assertIn('biz_inbound_item.test_batch_id = 7', count_sql) + + async def test_item_identities_join_pallet_and_inventory_in_same_scope(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10501', 'EMPTY_IN_WAREHOUSE'), (32, None, None)])) + + identities = await InboundSummaryDao.get_item_identities(db, 12, make_test_scope(7)) + + self.assertEqual({'pallet_code': 'TP-10501', 'flow_state': 'EMPTY_IN_WAREHOUSE'}, identities[31]) + self.assertEqual({'pallet_code': None, 'flow_state': None}, identities[32]) + sql = compile_sql(db.execute.await_args.args[0]) + for fragment in ( + 'biz_inbound_item.inbound_id = 12', + 'biz_inbound_item.test_batch_id = 7', + 'biz_pallet.test_batch_id = 7', + 'biz_pallet_inventory.test_batch_id = 7', + ): + self.assertIn(fragment, sql) + + +class InboundOverviewDaoTest(unittest.IsolatedAsyncioTestCase): + def _db(self) -> MagicMock: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock( + side_effect=[ + result_one((3, 26, datetime(2026, 10, 14, 9, 30))), + result_one((28, 1240)), + result_scalar(54), + result_rows([(2, '成品二仓', 126), (1, '成品一仓', 168), (3, None, 78)]), + ] + ) + return db + + async def test_overview_counts_and_sorts_empty_pallets_by_warehouse(self) -> None: + db = self._db() + + overview = await InboundSummaryDao.get_overview(db, [1, 2, 3], make_test_scope(7), datetime(2026, 10, 1)) + + self.assertEqual(3, overview['draft_count']) + self.assertEqual(26, overview['draft_pallet_count']) + self.assertEqual(datetime(2026, 10, 14, 9, 30), overview['oldest_draft_at']) + self.assertEqual(28, overview['month_confirmed_count']) + self.assertEqual(1240, overview['month_confirmed_pallet_count']) + self.assertEqual(54, overview['unstocked_pallet_count']) + self.assertEqual(372, overview['empty_pallet_count']) + self.assertEqual([1, 2, 3], [row['warehouse_id'] for row in overview['empty_by_warehouse']]) + InboundOverviewModel.model_validate(overview) + + draft_sql, month_sql, unstocked_sql, empty_sql = ( + compile_sql(call.args[0]) for call in db.execute.await_args_list + ) + for sql in (draft_sql, month_sql): + self.assertIn('biz_inbound_order.test_batch_id = 7', sql) + self.assertIn('biz_inbound_order.warehouse_id IN (1, 2, 3)', sql) + self.assertIn('biz_inbound_item.test_batch_id = 7', sql) + self.assertIn("biz_inbound_order.status = 'DRAFT'", draft_sql) + self.assertIn("biz_inbound_order.status = 'CONFIRMED'", month_sql) + self.assertIn("biz_inbound_order.confirmed_at >= '2026-10-01 00:00:00'", month_sql) + # 未入库托盘没有仓库归属,只受测试批次范围与资产状态约束 + self.assertIn("biz_pallet_inventory.flow_state = 'UNSTOCKED'", unstocked_sql) + self.assertIn("biz_pallet.asset_status = '0'", unstocked_sql) + self.assertIn('biz_pallet_inventory.test_batch_id = 7', unstocked_sql) + self.assertNotIn('warehouse_id IN', unstocked_sql) + self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql) + self.assertIn('biz_pallet_inventory.warehouse_id IN (1, 2, 3)', empty_sql) + self.assertIn('biz_warehouse.test_batch_id = 7', empty_sql) + + async def test_user_without_warehouses_sees_no_orders_or_empty_pallets(self) -> None: + db = self._db() + + await InboundSummaryDao.get_overview(db, [], TestBatchScope.formal(), datetime(2026, 10, 1)) + + draft_sql, month_sql, _unstocked_sql, empty_sql = ( + compile_sql(call.args[0]) for call in db.execute.await_args_list + ) + for sql in (draft_sql, month_sql, empty_sql): + self.assertIn('false', sql.lower()) + + async def test_admin_overview_has_no_warehouse_filter(self) -> None: + db = self._db() + + await InboundSummaryDao.get_overview(db, None, TestBatchScope.formal(), datetime(2026, 10, 1)) + + for call in db.execute.await_args_list: + self.assertNotIn('warehouse_id IN', compile_sql(call.args[0])) + + +class InboundServiceSummaryTest(unittest.IsolatedAsyncioTestCase): + async def test_list_rows_carry_summary_fields_in_camel_case(self) -> None: + record = {'inbound_id': 12, 'inbound_no': 'IN-1', 'warehouse_id': 7, 'status': 'DRAFT'} + page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False) + summary = { + 12: {'warehouse_name': '成品一仓', 'creator_name': '李娜', 'confirmer_name': None, 'pallet_count': 18} + } + with ( + patch.object(OperationDao, 'get_inbound_list', new=AsyncMock(return_value=page)), + patch.object(InboundSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries, + ): + result = await InboundService.get_inbound_list_services( + MagicMock(spec=AsyncSession), InboundPageQueryModel(), admin_user(), TestBatchScope.formal() + ) + + self.assertEqual([12], summaries.await_args.args[1]) + row = result.rows[0] + self.assertIsInstance(row, InboundListItemModel) + dumped = row.model_dump(by_alias=True) + self.assertEqual('成品一仓', dumped['warehouseName']) + self.assertEqual('李娜', dumped['creatorName']) + self.assertEqual(18, dumped['palletCount']) + + async def test_detail_items_carry_pallet_code_and_current_state(self) -> None: + inbound = { + 'inbound_id': 12, + 'inbound_no': 'IN-1', + 'warehouse_id': 7, + 'status': 'CONFIRMED', + 'items': [{'inbound_item_id': 31, 'inbound_id': 12, 'pallet_id': 501}], + } + identities = {31: {'pallet_code': 'TP-10501', 'flow_state': 'FILLED_IN_WAREHOUSE'}} + with ( + patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)), + patch.object(InboundSummaryDao, 'get_item_identities', new=AsyncMock(return_value=identities)), + ): + result = await InboundService.inbound_detail_services( + MagicMock(spec=AsyncSession), 12, admin_user(), TestBatchScope.formal() + ) + + item = result.model_dump(by_alias=True)['items'][0] + self.assertEqual('TP-10501', item['palletCode']) + self.assertEqual('FILLED_IN_WAREHOUSE', item['flowState']) + + async def test_overview_uses_warehouse_scope_and_month_start(self) -> None: + user = CurrentUserModel.model_construct( + permissions=['business:inbound:list'], + roles=['common'], + user=SimpleNamespace(user_id=5, user_name='ln', admin=False), + ) + overview = { + 'draft_count': 0, + 'draft_pallet_count': 0, + 'oldest_draft_at': None, + 'month_start': datetime(2026, 10, 1).date(), + 'month_confirmed_count': 0, + 'month_confirmed_pallet_count': 0, + 'unstocked_pallet_count': 0, + 'empty_pallet_count': 0, + 'empty_by_warehouse': [], + } + with ( + patch.object(InboundService, '_warehouse_scope', new=AsyncMock(return_value=[7])), + patch.object(InboundSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview, + ): + result = await InboundService.get_inbound_overview_services( + MagicMock(spec=AsyncSession), user, TestBatchScope.formal() + ) + + self.assertIsInstance(result, InboundOverviewModel) + self.assertEqual([7], get_overview.await_args.args[1]) + month_start = get_overview.await_args.args[3] + self.assertEqual((1, 0, 0), (month_start.day, month_start.hour, month_start.minute)) + + +if __name__ == '__main__': + unittest.main() diff --git a/shierfeng-fastapi-backend/tests/module_business/test_master_service.py b/shierfeng-fastapi-backend/tests/module_business/test_master_service.py index e91d487..d4ab160 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_master_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_master_service.py @@ -33,6 +33,14 @@ from module_business.entity.do.master_do import ( BizStrain, BizWarehouse, ) +from module_business.entity.vo.master_summary_vo import ( + CustomerDetailModel, + CustomerListItemModel, + LineListItemModel, + SiteListItemModel, + StrainListItemModel, + WarehouseListItemModel, +) from module_business.entity.vo.master_vo import ( CustomerModel, CustomerPageQueryModel, @@ -1060,17 +1068,24 @@ class TestMasterControllers(unittest.TestCase): def test_route_response_model_contracts(self) -> None: model_by_resource = { 'strain': StrainModel, - 'customer': CustomerModel, + 'customer': CustomerDetailModel, 'site': SiteModel, 'line': LineModel, 'warehouse': WarehouseModel, } + list_model_by_resource = { + 'strain': StrainListItemModel, + 'customer': CustomerListItemModel, + 'site': SiteListItemModel, + 'line': LineListItemModel, + 'warehouse': WarehouseListItemModel, + } for resource, router in self.ROUTERS.items(): for route in router.routes: if not isinstance(route, APIRoute): continue if route.path.endswith('/list'): - self.assertEqual(PageResponseModel[model_by_resource[resource]], route.response_model) + self.assertEqual(PageResponseModel[list_model_by_resource[resource]], route.response_model) elif route.methods == {'GET'}: self.assertEqual(DataResponseModel[model_by_resource[resource]], route.response_model) else: diff --git a/shierfeng-fastapi-backend/tests/module_business/test_master_summary.py b/shierfeng-fastapi-backend/tests/module_business/test_master_summary.py new file mode 100644 index 0000000..4d92af2 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/module_business/test_master_summary.py @@ -0,0 +1,351 @@ +import io +import unittest +from datetime import date, datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from openpyxl import Workbook +from sqlalchemy.dialects import mysql +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from exceptions.exception import ServiceException +from module_business.controller import line_controller, site_controller, tunnel_controller +from module_business.dao.master_dao import MasterDao +from module_business.dao.master_summary_dao import MasterSummaryDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.master_summary_vo import ( + CustomerDetailModel, + MasterImportPreviewModel, + PalletDetailModel, + PalletListItemModel, + WarehouseListItemModel, +) +from module_business.entity.vo.master_vo import ( + CustomerModel, + PalletModel, + PalletPageQueryModel, + WarehouseModel, +) +from module_business.service.master_excel_service import MasterExcelService +from module_business.service.master_summary_service import MasterSummaryService +from tests.module_business.test_batch_fixtures import make_admin, make_test_scope +from utils.page_util import PageUtil + + +def compile_sql(statement: Any) -> str: + compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}) + return ' '.join(str(compiled).split()).replace('%%', '%') + + +def result_rows(rows: list[Any]) -> MagicMock: + result = MagicMock() + result.all.return_value = rows + return result + + +class _Upload: + def __init__(self, content: bytes) -> None: + self.content = content + + async def read(self) -> bytes: + return self.content + + async def close(self) -> None: + return None + + +def workbook_bytes(headers: list[str], rows: list[list[object]]) -> bytes: + workbook = Workbook() + worksheet = workbook.active + worksheet.append(headers) + for row in rows: + worksheet.append(row) + buffer = io.BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + +SUMMARY_GETTERS = ( + 'get_strain_summaries', + 'get_customer_summaries', + 'get_site_summaries', + 'get_line_summaries', + 'get_tunnel_summaries', + 'get_warehouse_summaries', + 'get_pallet_summaries', +) + + +class MasterSummaryDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_empty_ids_skip_database(self) -> None: + for getter in SUMMARY_GETTERS: + db = MagicMock(spec=AsyncSession) + with self.subTest(getter=getter): + self.assertEqual({}, await getattr(MasterSummaryDao, getter)(db, [], TestBatchScope.formal())) + db.execute.assert_not_called() + + async def test_every_summary_query_is_bound_to_current_batch(self) -> None: + for getter in SUMMARY_GETTERS: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([])) + with self.subTest(getter=getter): + summaries = await getattr(MasterSummaryDao, getter)(db, [3, 3, 1], make_test_scope(7)) + self.assertEqual([1, 3], sorted(summaries)) + self.assertGreater(db.execute.await_count, 0) + for call in db.execute.await_args_list: + sql = compile_sql(call.args[0]) + self.assertIn('test_batch_id = 7', sql) + self.assertNotIn('test_batch_id IS NULL', sql) + + async def test_customer_site_tree_queries_are_bound_to_current_batch(self) -> None: + site = SimpleNamespace(site_id=5, site_code='S-01', site_name='南厂', address=None, status='0') + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(side_effect=[result_rows([site])] + [result_rows([])] * 4) + tree = await MasterSummaryDao.get_customer_site_tree(db, 2, make_test_scope(7)) + self.assertEqual(5, db.execute.await_count) + self.assertEqual([{'site_id': 5, 'site_code': 'S-01', 'site_name': '南厂', 'address': None, 'status': '0', + 'lines': [], 'tunnels': [], 'users': []}], tree) + for call in db.execute.await_args_list: + self.assertIn('test_batch_id = 7', compile_sql(call.args[0])) + + async def test_customer_without_sites_stops_after_first_query(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([])) + self.assertEqual([], await MasterSummaryDao.get_customer_site_tree(db, 2, TestBatchScope.formal())) + self.assertEqual(1, db.execute.await_count) + + async def test_pallet_summary_merges_state_cycles_and_return_target(self) -> None: + shipped_at = datetime(2026, 10, 1, 9, 0) + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock( + side_effect=[ + result_rows( + [ + (11, 'RETURNING', 0, None, '金禾菌业', '南厂', None, None, None, None, None), + (12, 'FILLED_IN_WAREHOUSE', 100, '成品一仓', None, None, None, None, '香菇', 'B-1', + date(2026, 10, 30)), + ] + ), + result_rows([(11, 3, shipped_at, shipped_at), (12, 0, None, shipped_at)]), + result_rows([(11, '周转仓')]), + ] + ) + summaries = await MasterSummaryDao.get_pallet_summaries(db, [12, 11], TestBatchScope.formal()) + self.assertEqual('RETURNING', summaries[11]['flow_state']) + self.assertEqual(3, summaries[11]['cycle_count']) + self.assertEqual('周转仓', summaries[11]['return_warehouse_name']) + self.assertEqual('成品一仓', summaries[12]['warehouse_name']) + self.assertEqual('香菇', summaries[12]['strain_name']) + self.assertNotIn('return_warehouse_name', summaries[12]) + sql = compile_sql(db.execute.await_args_list[2].args[0]) + self.assertIn("biz_return_order.status = 'REQUESTED'", sql) + + +class ParentOptionTest(unittest.IsolatedAsyncioTestCase): + async def test_customer_options_only_list_active_customers_in_batch(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([(2, 'C-01', '金禾菌业')])) + options = await MasterSummaryDao.list_customer_options(db, make_test_scope(7)) + self.assertEqual([{'id': 2, 'code': 'C-01', 'name': '金禾菌业'}], options) + sql = compile_sql(db.execute.await_args.args[0]) + self.assertIn("biz_customer.status = '0'", sql) + self.assertIn('biz_customer.test_batch_id = 7', sql) + self.assertIn('ORDER BY biz_customer.customer_code', sql) + + async def test_site_options_require_active_site_and_active_customer(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([(5, 'S-01', '南厂', '金禾菌业')])) + options = await MasterSummaryDao.list_site_options(db, TestBatchScope.formal()) + self.assertEqual([{'id': 5, 'code': 'S-01', 'name': '南厂', 'parent_name': '金禾菌业'}], options) + sql = compile_sql(db.execute.await_args.args[0]) + self.assertIn("biz_customer_site.status = '0'", sql) + self.assertIn("biz_customer.status = '0'", sql) + self.assertIn('biz_customer_site.test_batch_id IS NULL', sql) + self.assertIn('biz_customer.test_batch_id IS NULL', sql) + + async def test_service_wraps_options_in_camel_case_models(self) -> None: + rows = [{'id': 5, 'code': 'S-01', 'name': '南厂', 'parent_name': '金禾菌业'}] + with patch.object(MasterSummaryDao, 'list_site_options', new=AsyncMock(return_value=rows)): + options = await MasterSummaryService.site_options(MagicMock(spec=AsyncSession), TestBatchScope.formal()) + self.assertEqual({'id': 5, 'code': 'S-01', 'name': '南厂', 'parentName': '金禾菌业'}, options[0].model_dump(by_alias=True)) + + def test_option_routes_are_registered_before_detail_routes(self) -> None: + for module, path, permission in ( + (site_controller, '/master/site/customer-options', 'master:site:list'), + (line_controller, '/master/line/site-options', 'master:line:list'), + (tunnel_controller, '/master/tunnel/site-options', 'master:tunnel:list'), + ): + resource = path.split('/')[2] + option_router = getattr(module, f'{resource}_option_controller') + main_router = getattr(module, f'{resource}_controller') + with self.subTest(path=path): + self.assertLess(option_router.order_num, main_router.order_num) + (route,) = [route for route in option_router.routes if getattr(route, 'path', '') == path] + self.assertEqual({'GET'}, route.methods) + self.assertEqual( + [permission], + [dep.dependency.perm for dep in route.dependencies if hasattr(dep.dependency, 'perm')], + ) + + +class PalletListFilterTest(unittest.IsolatedAsyncioTestCase): + async def _capture(self, query: PalletPageQueryModel, scope: TestBatchScope) -> str: + with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate: + await MasterDao.list_pallet(MagicMock(spec=AsyncSession), query, scope, is_page=True) + return compile_sql(paginate.await_args.args[1]) + + async def test_flow_group_filters_by_inventory_state_in_same_batch(self) -> None: + sql = await self._capture(PalletPageQueryModel(flowGroup='site'), make_test_scope(7)) + self.assertIn('biz_pallet.pallet_id IN (SELECT biz_pallet_inventory.pallet_id', sql) + self.assertIn("'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY'", sql) + self.assertIn('biz_pallet_inventory.test_batch_id = 7', sql) + self.assertNotIn('NOT IN', sql) + + async def test_unstocked_group_includes_pallets_without_inventory(self) -> None: + sql = await self._capture(PalletPageQueryModel(flowGroup='unstocked'), TestBatchScope.formal()) + self.assertIn("biz_pallet_inventory.flow_state IN ('UNSTOCKED')", sql) + self.assertIn('biz_pallet.pallet_id NOT IN (SELECT biz_pallet_inventory.pallet_id', sql) + + async def test_code_search_also_matches_scanned_qr_token(self) -> None: + sql = await self._capture(PalletPageQueryModel(palletCode='tok-1'), TestBatchScope.formal()) + self.assertIn("biz_pallet.pallet_code LIKE '%tok-1%' OR biz_pallet.qr_token = 'tok-1'", sql) + + def test_flow_group_rejects_unknown_value(self) -> None: + with self.assertRaises(ValueError): + PalletPageQueryModel(flowGroup='lost') + + +class MasterSummaryServiceTest(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.db = MagicMock(spec=AsyncSession) + self.scope = TestBatchScope.formal() + + async def test_enrich_page_merges_summaries_into_list_rows(self) -> None: + page = PageModel[Any]( + rows=[ + WarehouseModel(warehouseId=1, warehouseCode='W-01', warehouseName='成品一仓'), + WarehouseModel(warehouseId=2, warehouseCode='W-02', warehouseName='周转仓'), + ], + pageNum=1, + pageSize=10, + total=2, + hasNext=False, + ) + loader = AsyncMock(return_value={1: {'filled_pallet_count': 4, 'users': [{'user_id': 3, 'user_name': 'li'}]}}) + with patch.object(MasterSummaryDao, 'get_warehouse_summaries', new=loader), patch.dict( + 'module_business.service.master_summary_service._ENRICHERS', + {'warehouse': ('warehouse_id', loader, WarehouseListItemModel)}, + ): + result = await MasterSummaryService.enrich_page(self.db, 'warehouse', page, self.scope) + + loader.assert_awaited_once_with(self.db, [1, 2], self.scope) + self.assertEqual(2, result.total) + self.assertIsInstance(result.rows[0], WarehouseListItemModel) + self.assertEqual(4, result.rows[0].filled_pallet_count) + self.assertEqual('li', result.rows[0].users[0].user_name) + self.assertEqual(0, result.rows[1].filled_pallet_count) + dumped = result.model_dump(by_alias=True)['rows'][0] + self.assertEqual(4, dumped['filledPalletCount']) + self.assertEqual('成品一仓', dumped['warehouseName']) + + async def test_customer_detail_carries_site_tree(self) -> None: + tree = [{'site_id': 5, 'site_code': 'S-01', 'site_name': '南厂', 'status': '0', 'unused_pallet_count': 2}] + with patch.object(MasterSummaryDao, 'get_customer_site_tree', new=AsyncMock(return_value=tree)) as loader: + detail = await MasterSummaryService.customer_detail( + self.db, + CustomerModel(customerId=2, customerCode='C-01', customerName='金禾菌业'), + self.scope, + ) + loader.assert_awaited_once_with(self.db, 2, self.scope) + self.assertIsInstance(detail, CustomerDetailModel) + self.assertEqual('C-01', detail.customer_code) + self.assertEqual(2, detail.sites[0].unused_pallet_count) + + async def test_pallet_detail_adds_state_and_recent_events(self) -> None: + pallet = PalletModel(palletId=9, palletCode='P-9', qrToken='tok', capacityPackages=100, assetStatus='0') + event = { + 'event_id': 1, + 'event_type': 'USAGE_RECORDED', + 'occurred_at': datetime(2026, 10, 16, 9, 12), + 'after_state': 'AT_SITE_PARTIAL', + 'before_remaining_packages': 50, + 'after_remaining_packages': 38, + } + with patch.object( + MasterSummaryDao, + 'get_pallet_summaries', + new=AsyncMock(return_value={9: {'flow_state': 'AT_SITE_PARTIAL', 'remaining_packages': 38}}), + ), patch.object(MasterSummaryDao, 'get_pallet_recent_events', new=AsyncMock(return_value=[event])): + detail = await MasterSummaryService.pallet_detail(self.db, pallet, self.scope) + self.assertIsInstance(detail, PalletDetailModel) + self.assertIsInstance(detail, PalletListItemModel) + self.assertEqual('tok', detail.qr_token) + self.assertEqual(38, detail.remaining_packages) + self.assertEqual('USAGE_RECORDED', detail.recent_events[0].event_type) + + +class MasterImportPreviewTest(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.db = AsyncMock(spec=AsyncSession) + self.db.execute = AsyncMock() + self.headers = ['菌种编码', '菌种名称', '状态', '备注'] + + async def _preview(self, rows: list[list[object]], *, update_support: bool = False) -> MasterImportPreviewModel: + return await MasterExcelService.preview_import( + self.db, + 'strain', + _Upload(workbook_bytes(self.headers, rows)), # type: ignore[arg-type] + update_support, + make_admin(), + TestBatchScope.formal(), + ) + + async def test_preview_reports_each_row_and_never_commits(self) -> None: + existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None) + + async def find(_db: object, code: str) -> object: + return existing if code == 'S001' else None + + with patch.object(MasterDao, 'find_strain_by_code', new=find), patch.object( + MasterDao, 'get_strain', new=AsyncMock(return_value=existing) + ), patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_strain, patch.object( + MasterDao, 'update_strain', new=AsyncMock() + ): + preview = await self._preview( + [['S001', '香菇', '正常', ''], ['S002', '平菇', '正常', ''], ['S003', '金针菇', '启用', '']] + ) + + self.assertEqual(['菌种编码', '菌种名称', '状态', '备注'], preview.headers) + self.assertEqual(3, preview.total_rows) + self.assertEqual((1, 0, 1, 1), (preview.add_count, preview.update_count, preview.skip_count, preview.error_count)) + self.assertEqual(['skip', 'add', 'error'], [row.outcome for row in preview.rows]) + self.assertEqual([2, 3, 4], [row.excel_row for row in preview.rows]) + self.assertIn('状态只能填写正常或停用', preview.rows[2].message) + self.assertEqual(['S002', '平菇', '正常', ''], preview.rows[1].values) + add_strain.assert_awaited_once() + self.db.commit.assert_not_awaited() + self.db.rollback.assert_awaited_once() + + async def test_preview_with_update_support_marks_existing_rows_as_update(self) -> None: + existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None) + with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=existing)), patch.object( + MasterDao, 'get_strain', new=AsyncMock(return_value=existing) + ), patch.object(MasterDao, 'update_strain', new=AsyncMock()): + preview = await self._preview([['S001', '新香菇', '停用', '']], update_support=True) + self.assertEqual('update', preview.rows[0].outcome) + self.assertTrue(preview.update_support) + self.db.commit.assert_not_awaited() + self.db.rollback.assert_awaited_once() + + async def test_whole_file_errors_are_raised_before_any_row_is_checked(self) -> None: + with self.assertRaises(ServiceException) as raised: + await self._preview([['S001', '香菇', '正常', ''], ['S001', '香菇', '正常', '']]) + self.assertIn('重复编码', raised.exception.message) + self.db.execute.assert_not_awaited() + self.db.commit.assert_not_awaited() + + +if __name__ == '__main__': + unittest.main() diff --git a/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py b/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py index 1e437d4..b1cf06e 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_pallet_service.py @@ -34,6 +34,7 @@ from module_business.entity.do.master_do import ( BizWarehouse, ) from module_business.entity.do.operation_do import BizPalletInventory +from module_business.entity.vo.master_summary_vo import PalletDetailModel, PalletListItemModel from module_business.entity.vo.master_vo import ( BusinessUserOptionModel, DeletePalletModel, @@ -1285,11 +1286,11 @@ class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase): def test_route_response_models_are_exact(self) -> None: routes = self._routes() self.assertEqual( - PageResponseModel[PalletModel], + PageResponseModel[PalletListItemModel], routes[('/master/pallet/list', 'GET')].response_model, ) self.assertEqual( - DataResponseModel[PalletModel], + DataResponseModel[PalletDetailModel], routes[('/master/pallet/{pallet_id}', 'GET')].response_model, ) for path, method in ( diff --git a/shierfeng-fastapi-backend/tests/module_business/test_return_service.py b/shierfeng-fastapi-backend/tests/module_business/test_return_service.py index 8dd17d3..6675a89 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_return_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_return_service.py @@ -38,10 +38,13 @@ from module_business.entity.vo.operation_vo import ( BusinessOptionModel, PalletScanResponseModel, ReturnConfirmModel, + ReturnListItemModel, ReturnModel, + ReturnOverviewModel, ReturnPageQueryModel, ReturnRequestModel, ReturnScanQueryModel, + ReturnSiteOptionModel, ) from module_business.service.access_service import AccessService from module_business.service.test_batch_access_service import TestBatchAccessService @@ -440,6 +443,8 @@ class ReturnControllerContractRedTest(unittest.TestCase): schema = app.openapi() expected = { ('/business/return/list', 'get'), + ('/business/return/summary', 'get'), + ('/business/return/site-options', 'get'), ('/business/return/warehouse-options', 'get'), ('/business/return/scan/{qr_token}', 'get'), ('/business/return/{return_id}', 'get'), @@ -450,7 +455,17 @@ class ReturnControllerContractRedTest(unittest.TestCase): parameters = schema['paths']['/business/return/scan/{qr_token}']['get']['parameters'] self.assertEqual({'qr_token', 'mode', 'returnId'}, {parameter['name'] for parameter in parameters}) routes = self._routes() - self.assertEqual(PageResponseModel[ReturnModel], routes[('/business/return/list', 'GET')].response_model) + self.assertEqual( + PageResponseModel[ReturnListItemModel], routes[('/business/return/list', 'GET')].response_model + ) + self.assertEqual( + DataResponseModel[ReturnOverviewModel], + routes[('/business/return/summary', 'GET')].response_model, + ) + self.assertEqual( + DataResponseModel[list[ReturnSiteOptionModel]], + routes[('/business/return/site-options', 'GET')].response_model, + ) self.assertEqual( DataResponseModel[list[BusinessOptionModel]], routes[('/business/return/warehouse-options', 'GET')].response_model, @@ -472,6 +487,8 @@ class ReturnControllerContractRedTest(unittest.TestCase): detail_index = paths.index('/business/return/{return_id}') for static_path in ( '/business/return/list', + '/business/return/summary', + '/business/return/site-options', '/business/return/warehouse-options', '/business/return/scan/{qr_token}', '/business/return/request', @@ -480,6 +497,8 @@ class ReturnControllerContractRedTest(unittest.TestCase): expected = { ('/business/return/list', 'GET'): 'business:return:list', + ('/business/return/summary', 'GET'): 'business:return:list', + ('/business/return/site-options', 'GET'): 'business:return:list', ('/business/return/warehouse-options', 'GET'): 'business:return:query', ('/business/return/scan/{qr_token}', 'GET'): 'business:return:query', ('/business/return/{return_id}', 'GET'): 'business:return:query', diff --git a/shierfeng-fastapi-backend/tests/module_business/test_return_summary.py b/shierfeng-fastapi-backend/tests/module_business/test_return_summary.py new file mode 100644 index 0000000..0e179cd --- /dev/null +++ b/shierfeng-fastapi-backend/tests/module_business/test_return_summary.py @@ -0,0 +1,408 @@ +import unittest +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from sqlalchemy.dialects import mysql +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dao.access_dao import AccessDao +from module_business.dao.operation_dao import OperationDao +from module_business.dao.return_summary_dao import ReturnSummaryDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.do.operation_do import BizReturnItem, BizReturnOrder +from module_business.entity.vo.operation_vo import ( + ReturnListItemModel, + ReturnOverviewModel, + ReturnPageQueryModel, + ReturnSiteOptionModel, +) +from module_business.service.return_service import ReturnService +from utils.page_util import PageUtil + + +def compile_sql(statement: Any) -> str: + compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}) + return ' '.join(str(compiled).split()).replace('%%', '%') + + +def make_test_scope(batch_id: int) -> TestBatchScope: + return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True) + + +def result_rows(rows: list[tuple[Any, ...]]) -> MagicMock: + result = MagicMock() + result.all.return_value = rows + return result + + +def result_one(row: tuple[Any, ...]) -> MagicMock: + result = MagicMock() + result.one.return_value = row + return result + + +def admin_user() -> CurrentUserModel: + return CurrentUserModel.model_construct( + permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True) + ) + + +def normal_user() -> CurrentUserModel: + return CurrentUserModel.model_construct( + permissions=['business:return:list'], + roles=['common'], + user=SimpleNamespace(user_id=5, user_name='ln', admin=False), + ) + + +class ReturnListFilterTest(unittest.IsolatedAsyncioTestCase): + async def _capture(self, query: ReturnPageQueryModel, scope: TestBatchScope) -> str: + with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate: + await OperationDao.get_return_list( + MagicMock(spec=AsyncSession), + query, + site_ids=[9], + warehouse_ids=[7], + scope=scope, + ) + return compile_sql(paginate.await_args.args[1]) + + async def test_keyword_matches_number_names_and_pallet_code_within_same_batch_scope(self) -> None: + sql = await self._capture(ReturnPageQueryModel(keyword='TP-104'), make_test_scope(7)) + for fragment in ( + "biz_return_order.return_no LIKE '%TP-104%'", + "source_site.site_name LIKE '%TP-104%'", + "source_customer.customer_name LIKE '%TP-104%'", + "target_warehouse.warehouse_name LIKE '%TP-104%'", + "biz_pallet.pallet_code LIKE '%TP-104%'", + 'EXISTS (SELECT biz_return_item.return_item_id', + 'biz_return_item.return_id = biz_return_order.return_id', + 'biz_return_item.test_batch_id = 7', + 'biz_pallet.test_batch_id = 7', + 'source_site.test_batch_id = 7', + 'source_customer.test_batch_id = 7', + 'target_warehouse.test_batch_id = 7', + # 关键字只在原可见范围内匹配 + 'biz_return_order.source_site_id IN (9)', + 'biz_return_order.target_warehouse_id IN (7)', + ): + self.assertIn(fragment, sql) + + async def test_without_keyword_list_does_not_join_master_tables(self) -> None: + sql = await self._capture(ReturnPageQueryModel(), TestBatchScope.formal()) + self.assertNotIn('JOIN', sql) + self.assertNotIn('EXISTS', sql) + + def test_keyword_rejects_blank_value(self) -> None: + with self.assertRaises(ValueError): + ReturnPageQueryModel(keyword=' ') + + +class ReturnListSummaryDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_empty_ids_skip_database(self) -> None: + db = MagicMock(spec=AsyncSession) + self.assertEqual({}, await ReturnSummaryDao.get_list_summaries(db, [], TestBatchScope.formal())) + db.execute.assert_not_called() + + async def test_aggregates_names_and_snapshot_totals_within_batch_scope(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock( + side_effect=[ + result_rows([(12, '南厂', '金禾菌业', '成品一仓', '赵强', None), (13, None, None, None, None, None)]), + result_rows([(12, 12, 520, 7)]), + ] + ) + + summaries = await ReturnSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7)) + + self.assertEqual( + { + 'source_site_name': '南厂', + 'customer_name': '金禾菌业', + 'target_warehouse_name': '成品一仓', + 'requester_name': '赵强', + 'confirmer_name': None, + 'pallet_count': 12, + 'package_sum': 520, + 'filled_count': 7, + }, + summaries[12], + ) + # 没有明细的单据汇总为 0,名称缺失保持为空 + self.assertEqual(0, summaries[13]['pallet_count']) + self.assertEqual(0, summaries[13]['package_sum']) + self.assertIsNone(summaries[13]['source_site_name']) + + name_sql = compile_sql(db.execute.await_args_list[0].args[0]) + item_sql = compile_sql(db.execute.await_args_list[1].args[0]) + for fragment in ( + 'biz_return_order.return_id IN (12, 13)', + 'biz_return_order.test_batch_id = 7', + 'source_site.test_batch_id = 7', + 'source_customer.test_batch_id = 7', + 'target_warehouse.test_batch_id = 7', + 'requester.user_id = biz_return_order.requested_by', + 'confirmer.user_id = biz_return_order.confirmed_by', + "requester.del_flag = '0'", + ): + self.assertIn(fragment, name_sql) + self.assertIn('biz_return_item.return_id IN (12, 13)', item_sql) + self.assertIn('biz_return_item.test_batch_id = 7', item_sql) + self.assertIn('WHEN (biz_return_item.remaining_packages_snapshot > 0) THEN 1 ELSE 0', item_sql) + + async def test_item_identities_join_pallet_and_snapshot_tunnel_in_same_scope(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10466', '2 号隧道'), (32, 'TP-10468', None)])) + + identities = await ReturnSummaryDao.get_item_identities(db, 12, make_test_scope(7)) + + self.assertEqual({'pallet_code': 'TP-10466', 'tunnel_name': '2 号隧道'}, identities[31]) + self.assertEqual({'pallet_code': 'TP-10468', 'tunnel_name': None}, identities[32]) + sql = compile_sql(db.execute.await_args.args[0]) + for fragment in ( + 'biz_return_item.return_id = 12', + 'biz_return_item.test_batch_id = 7', + 'biz_pallet.test_batch_id = 7', + 'biz_tunnel.tunnel_id = biz_return_item.tunnel_id_snapshot', + 'biz_tunnel.test_batch_id = 7', + ): + self.assertIn(fragment, sql) + + +class ReturnSiteOptionsDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_user_sees_assigned_sites_and_sources_of_visible_returns(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([(9, 'JH-S', '南厂', '金禾菌业')])) + + options = await ReturnSummaryDao.get_site_options(db, [9, 9], [7], make_test_scope(4)) + + self.assertEqual([{'id': 9, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业'}], options) + sql = compile_sql(db.execute.await_args.args[0]) + for fragment in ( + 'biz_customer_site.test_batch_id = 4', + 'biz_customer.test_batch_id = 4', + 'biz_customer_site.site_id IN (9)', + 'biz_customer_site.site_id IN (SELECT biz_return_order.source_site_id', + 'biz_return_order.test_batch_id = 4', + 'biz_return_order.target_warehouse_id IN (7)', + ): + self.assertIn(fragment, sql) + + async def test_user_without_mappings_gets_no_sites(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([])) + + await ReturnSummaryDao.get_site_options(db, [], [], TestBatchScope.formal()) + + sql = compile_sql(db.execute.await_args.args[0]).lower() + self.assertIn('false', sql) + self.assertNotIn('site_id in (9)', sql) + + async def test_admin_sees_all_sites_in_batch_scope(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([])) + + await ReturnSummaryDao.get_site_options(db, None, None, TestBatchScope.formal()) + + sql = compile_sql(db.execute.await_args.args[0]) + self.assertIn('biz_customer_site.test_batch_id IS NULL', sql) + self.assertNotIn('biz_return_order', sql) + + +class ReturnOverviewDaoTest(unittest.IsolatedAsyncioTestCase): + def _db(self) -> MagicMock: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock( + side_effect=[ + result_one((3, 26, 9, 610, datetime(2026, 10, 15, 9, 5))), + result_rows( + [ + (4, datetime(2026, 10, 14, 16, 0), datetime(2026, 10, 15, 8, 0), 16, 4, 210), + (5, datetime(2026, 10, 12, 11, 0), datetime(2026, 10, 13, 11, 0), 10, 0, None), + ] + ), + ] + ) + return db + + async def test_overview_totals_pending_and_month_outcome(self) -> None: + db = self._db() + + overview = await ReturnSummaryDao.get_overview(db, [9], [7], make_test_scope(7), datetime(2026, 10, 1)) + + self.assertEqual(3, overview['pending_count']) + self.assertEqual(26, overview['pending_pallet_count']) + self.assertEqual(9, overview['pending_filled_count']) + self.assertEqual(610, overview['pending_package_sum']) + self.assertEqual(datetime(2026, 10, 15, 9, 5), overview['oldest_pending_at']) + self.assertEqual(2, overview['month_confirmed_count']) + self.assertEqual(26, overview['month_confirmed_pallet_count']) + self.assertEqual(4, overview['month_filled_pallet_count']) + self.assertEqual(210, overview['month_package_sum']) + # (16 + 24) / 2 小时 + self.assertEqual(20.0, overview['month_avg_confirm_hours']) + ReturnOverviewModel.model_validate(overview) + + pending_sql, month_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list) + for sql in (pending_sql, month_sql): + self.assertIn('biz_return_order.test_batch_id = 7', sql) + self.assertIn('biz_return_item.test_batch_id = 7', sql) + self.assertIn( + '(biz_return_order.source_site_id IN (9) OR biz_return_order.target_warehouse_id IN (7))', + sql, + ) + self.assertIn("biz_return_order.status = 'REQUESTED'", pending_sql) + self.assertIn("biz_return_order.status = 'CONFIRMED'", month_sql) + self.assertIn("biz_return_order.confirmed_at >= '2026-10-01 00:00:00'", month_sql) + + async def test_empty_month_has_no_average(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(side_effect=[result_one((0, 0, None, None, None)), result_rows([])]) + + overview = await ReturnSummaryDao.get_overview(db, [], [], TestBatchScope.formal(), datetime(2026, 10, 1)) + + self.assertEqual(0, overview['pending_filled_count']) + self.assertEqual(0, overview['month_confirmed_count']) + self.assertIsNone(overview['month_avg_confirm_hours']) + ReturnOverviewModel.model_validate(overview) + for call in db.execute.await_args_list: + self.assertIn('false', compile_sql(call.args[0]).lower()) + + async def test_admin_overview_has_no_visibility_filter(self) -> None: + db = self._db() + + await ReturnSummaryDao.get_overview(db, None, None, TestBatchScope.formal(), datetime(2026, 10, 1)) + + for call in db.execute.await_args_list: + sql = compile_sql(call.args[0]) + self.assertNotIn('source_site_id IN', sql) + self.assertNotIn('target_warehouse_id IN', sql) + + +class ReturnServiceSummaryTest(unittest.IsolatedAsyncioTestCase): + async def test_list_rows_carry_summary_fields_in_camel_case(self) -> None: + record = { + 'return_id': 12, + 'return_no': 'RE-1', + 'source_site_id': 9, + 'target_warehouse_id': 7, + 'status': 'REQUESTED', + 'request_idempotency_key': 'k', + 'request_hash': 'h' * 64, + 'requested_by': 8, + 'requested_at': datetime(2026, 10, 17, 10, 20), + } + page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False) + summary = { + 12: { + 'source_site_name': '南厂', + 'customer_name': '金禾菌业', + 'target_warehouse_name': '成品一仓', + 'requester_name': '赵强', + 'confirmer_name': None, + 'pallet_count': 12, + 'package_sum': 520, + 'filled_count': 7, + } + } + with ( + patch.object(OperationDao, 'get_return_list', new=AsyncMock(return_value=page)), + patch.object(ReturnSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries, + ): + result = await ReturnService.get_return_list_services( + MagicMock(spec=AsyncSession), ReturnPageQueryModel(), admin_user(), TestBatchScope.formal() + ) + + self.assertEqual([12], summaries.await_args.args[1]) + row = result.rows[0] + self.assertIsInstance(row, ReturnListItemModel) + dumped = row.model_dump(by_alias=True) + self.assertEqual('南厂', dumped['sourceSiteName']) + self.assertEqual('金禾菌业', dumped['customerName']) + self.assertEqual('成品一仓', dumped['targetWarehouseName']) + self.assertEqual(520, dumped['packageSum']) + self.assertEqual(7, dumped['filledCount']) + + async def test_detail_items_carry_pallet_code_and_tunnel_name(self) -> None: + order = BizReturnOrder( + return_id=12, + return_no='RE-1', + source_site_id=9, + target_warehouse_id=7, + status='REQUESTED', + request_idempotency_key='k', + request_hash='h' * 64, + requested_by=8, + requested_at=datetime(2026, 10, 17, 10, 20), + test_batch_id=None, + ) + order.items = [ + BizReturnItem(return_item_id=31, return_id=12, pallet_id=501, remaining_packages_snapshot=60), + ] + identities = {31: {'pallet_code': 'TP-10466', 'tunnel_name': '2 号隧道'}} + with ( + patch.object(OperationDao, 'get_return_by_id', new=AsyncMock(return_value=order)), + patch.object(ReturnSummaryDao, 'get_item_identities', new=AsyncMock(return_value=identities)), + ): + result = await ReturnService.return_detail_services( + MagicMock(spec=AsyncSession), 12, admin_user(), TestBatchScope.formal() + ) + + item = result.model_dump(by_alias=True)['items'][0] + self.assertEqual(501, item['palletId']) + self.assertEqual('TP-10466', item['palletCode']) + self.assertEqual('2 号隧道', item['tunnelName']) + + async def test_overview_uses_site_and_warehouse_scope_and_month_start(self) -> None: + overview = { + 'pending_count': 0, + 'pending_pallet_count': 0, + 'pending_filled_count': 0, + 'pending_package_sum': 0, + 'oldest_pending_at': None, + 'month_start': datetime(2026, 10, 1).date(), + 'month_confirmed_count': 0, + 'month_confirmed_pallet_count': 0, + 'month_filled_pallet_count': 0, + 'month_package_sum': 0, + 'month_avg_confirm_hours': None, + } + with ( + patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 9])), + patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])), + patch.object(ReturnSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview, + ): + result = await ReturnService.get_return_overview_services( + MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal() + ) + + self.assertIsInstance(result, ReturnOverviewModel) + self.assertEqual([9], get_overview.await_args.args[1]) + self.assertEqual([7], get_overview.await_args.args[2]) + month_start = get_overview.await_args.args[4] + self.assertEqual((1, 0, 0), (month_start.day, month_start.hour, month_start.minute)) + + async def test_site_options_return_models_with_customer_name(self) -> None: + rows = [{'id': 9, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业'}] + with ( + patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), + patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])), + patch.object(ReturnSummaryDao, 'get_site_options', new=AsyncMock(return_value=rows)) as get_options, + ): + result = await ReturnService.get_site_options_services( + MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal() + ) + + self.assertEqual(([9], []), get_options.await_args.args[1:3]) + self.assertIsInstance(result[0], ReturnSiteOptionModel) + self.assertEqual( + {'id': 9, 'code': 'JH-S', 'name': '南厂', 'customerName': '金禾菌业'}, result[0].model_dump(by_alias=True) + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/shierfeng-fastapi-backend/tests/module_business/test_shipment_list_summary.py b/shierfeng-fastapi-backend/tests/module_business/test_shipment_list_summary.py new file mode 100644 index 0000000..7d32c5a --- /dev/null +++ b/shierfeng-fastapi-backend/tests/module_business/test_shipment_list_summary.py @@ -0,0 +1,186 @@ +import unittest +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from sqlalchemy.dialects import mysql +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dao.access_dao import AccessDao +from module_business.dao.operation_dao import OperationDao +from module_business.dao.shipment_summary_dao import ShipmentSummaryDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.operation_vo import ShipmentListItemModel, ShipmentPageQueryModel +from module_business.service.shipment_service import ShipmentService +from utils.page_util import PageUtil + + +def compile_sql(statement: Any) -> str: + compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}) + return ' '.join(str(compiled).split()).replace('%%', '%') + + +def make_test_scope(batch_id: int) -> TestBatchScope: + return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True) + + +def result_rows(rows: list[tuple[Any, ...]]) -> MagicMock: + result = MagicMock() + result.all.return_value = rows + return result + + +class ShipmentListFilterTest(unittest.IsolatedAsyncioTestCase): + async def _capture(self, query: ShipmentPageQueryModel, scope: TestBatchScope) -> str: + with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate: + await OperationDao.get_shipment_list( + MagicMock(spec=AsyncSession), + query, + warehouse_ids=[7], + site_ids=[9], + scope=scope, + ) + return compile_sql(paginate.await_args.args[1]) + + async def test_receipt_state_filter_only_matches_confirmed_shipments(self) -> None: + sql = await self._capture(ShipmentPageQueryModel(receipt_state='PENDING'), TestBatchScope.formal()) + self.assertIn("biz_shipment_order.status = 'CONFIRMED'", sql) + self.assertIn("biz_shipment_order.receipt_state = 'PENDING'", sql) + + async def test_keyword_matches_number_and_names_within_same_batch_scope(self) -> None: + sql = await self._capture(ShipmentPageQueryModel(keyword='金禾'), make_test_scope(7)) + for fragment in ( + "biz_shipment_order.shipment_no LIKE '%金禾%'", + "source_warehouse.warehouse_name LIKE '%金禾%'", + "target_site.site_name LIKE '%金禾%'", + "target_customer.customer_name LIKE '%金禾%'", + 'source_warehouse.test_batch_id = 7', + 'target_site.test_batch_id = 7', + 'target_customer.test_batch_id = 7', + 'biz_shipment_order.source_warehouse_id IN (7)', + 'biz_shipment_order.target_site_id IN (9)', + ): + self.assertIn(fragment, sql) + + async def test_without_keyword_list_does_not_join_master_tables(self) -> None: + sql = await self._capture(ShipmentPageQueryModel(), TestBatchScope.formal()) + self.assertNotIn('JOIN', sql) + + def test_keyword_rejects_blank_value(self) -> None: + with self.assertRaises(ValueError): + ShipmentPageQueryModel(keyword=' ') + + +class ShipmentSummaryDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_empty_ids_skip_database(self) -> None: + db = MagicMock(spec=AsyncSession) + self.assertEqual({}, await ShipmentSummaryDao.get_list_summaries(db, [], TestBatchScope.formal())) + db.execute.assert_not_called() + + async def test_aggregates_names_packages_and_strains_per_shipment(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock( + side_effect=[ + result_rows([(12, '成品一仓', '南厂', '金禾菌业', '李娜'), (13, '成品二仓', None, None, None)]), + result_rows( + [ + (12, 100, None, 'A15', 'A15'), + (12, 100, None, 'S130', None), + (12, 100, None, 'A15', 'A15'), + (13, None, 40, 'B22', 'B22'), + (13, None, None, None, None), + ] + ), + ] + ) + + summaries = await ShipmentSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7)) + + self.assertEqual( + { + 'source_warehouse_name': '成品一仓', + 'target_site_name': '南厂', + 'customer_name': '金禾菌业', + 'creator_name': '李娜', + 'pallet_count': 3, + 'package_qty': 300, + 'strains': [{'strain_name': 'A15', 'pallet_count': 2}, {'strain_name': 'S130', 'pallet_count': 1}], + }, + summaries[12], + ) + # 草稿中已不可发货的托盘计入托盘数,但不计入包数 + self.assertEqual(2, summaries[13]['pallet_count']) + self.assertEqual(40, summaries[13]['package_qty']) + self.assertEqual([{'strain_name': 'B22', 'pallet_count': 1}], summaries[13]['strains']) + + name_sql = compile_sql(db.execute.await_args_list[0].args[0]) + item_sql = compile_sql(db.execute.await_args_list[1].args[0]) + self.assertIn('biz_shipment_order.shipment_id IN (12, 13)', name_sql) + for fragment in ( + 'biz_shipment_order.test_batch_id = 7', + 'source_warehouse.test_batch_id = 7', + 'target_site.test_batch_id = 7', + 'target_customer.test_batch_id = 7', + ): + self.assertIn(fragment, name_sql) + for fragment in ( + 'biz_shipment_item.shipment_id IN (12, 13)', + 'biz_shipment_item.test_batch_id = 7', + 'biz_pallet_inventory.test_batch_id = 7', + 'biz_filling_record.test_batch_id = 7', + "biz_shipment_order.status = 'DRAFT'", + "biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'", + ): + self.assertIn(fragment, item_sql) + + +class ShipmentListServiceSummaryTest(unittest.IsolatedAsyncioTestCase): + async def test_rows_carry_summary_fields_in_camel_case(self) -> None: + record = { + 'shipment_id': 12, + 'shipment_no': 'SH-1', + 'source_warehouse_id': 7, + 'target_site_id': 9, + 'status': 'CONFIRMED', + 'receipt_state': 'PENDING', + } + page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False) + summary = { + 12: { + 'source_warehouse_name': '成品一仓', + 'target_site_name': '南厂', + 'customer_name': '金禾菌业', + 'creator_name': '李娜', + 'pallet_count': 2, + 'package_qty': 200, + 'strains': [{'strain_name': 'A15', 'pallet_count': 2}], + } + } + user = CurrentUserModel.model_construct( + permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, admin=True) + ) + with ( + patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])), + patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])), + patch.object(OperationDao, 'get_shipment_list', new=AsyncMock(return_value=page)), + patch.object(ShipmentSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries, + patch.object(ShipmentService, '_scopes', new=AsyncMock(return_value=([7], [9]))), + ): + result = await ShipmentService.get_shipment_list_services( + MagicMock(spec=AsyncSession), ShipmentPageQueryModel(), user, TestBatchScope.formal() + ) + + summaries.assert_awaited_once() + self.assertEqual([12], summaries.await_args.args[1]) + row = result.rows[0] + self.assertIsInstance(row, ShipmentListItemModel) + dumped = row.model_dump(by_alias=True) + self.assertEqual('成品一仓', dumped['sourceWarehouseName']) + self.assertEqual(200, dumped['packageQty']) + self.assertEqual([{'strainName': 'A15', 'palletCount': 2}], dumped['strains']) + + +if __name__ == '__main__': + unittest.main() diff --git a/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py b/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py index 0aa0ee9..17d3f3e 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py @@ -31,6 +31,8 @@ from module_business.dao.access_dao import AccessDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao +from module_business.dao.shipment_label_dao import ShipmentLabelDao +from module_business.dao.shipment_summary_dao import ShipmentSummaryDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.test_batch_scope import TestBatchScope @@ -47,6 +49,7 @@ from module_business.entity.vo.operation_vo import ( ConfirmCommandModel, PalletScanResponseModel, ShipmentCreateModel, + ShipmentListItemModel, ShipmentModel, ShipmentPageQueryModel, ShipmentScanQueryModel, @@ -425,7 +428,7 @@ class ShipmentControllerContractTest(unittest.TestCase): ) routes = self._routes() - self.assertEqual(PageResponseModel[ShipmentModel], routes[('/business/shipment/list', 'GET')].response_model) + self.assertEqual(PageResponseModel[ShipmentListItemModel], routes[('/business/shipment/list', 'GET')].response_model) for path in ('warehouse-options', 'site-options'): self.assertEqual( DataResponseModel[list[BusinessOptionModel]], @@ -545,6 +548,8 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase): patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 9])), patch.object(OperationDao, 'get_shipment_list', new=get_list), patch.object(OperationDao, 'get_shipment_by_id', new=get_detail), + patch.object(ShipmentSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})), + patch.object(ShipmentLabelDao, 'get_label_sources', new=AsyncMock(return_value=[])), ): listed = await ShipmentService.get_shipment_list_services( self.db, diff --git a/shierfeng-fastapi-backend/tests/module_business/test_usage_fifo.py b/shierfeng-fastapi-backend/tests/module_business/test_usage_fifo.py index 83e5f8e..296171a 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_usage_fifo.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_usage_fifo.py @@ -12,6 +12,7 @@ from module_business.dao.usage_fifo_dao import UsageFifoDao from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory +from module_business.service.access_service import AccessService from module_business.service.usage_fifo_service import UsageFifoService from module_business.service.usage_service import UsageService from module_business.dao.master_dao import MasterDao @@ -188,3 +189,53 @@ class TestUsageFifo(unittest.IsolatedAsyncioTestCase): self.db, 'qr-2', MagicMock(), TestBatchScope.formal(), tunnel_id=13) self.assertEqual(result.recommended.pallet_id, 1) self.assertTrue(result.requires_skip_reason) + + async def test_queue_groups_by_strain_in_recommendation_order(self) -> None: + # 同菌种按实际收货时间排序;已入隧和不可用的托盘不进入待使用队列 + self.add(1, datetime(2026, 10, 8)) + self.add(2, datetime(2026, 10, 6), remaining=80, state='AT_SITE_PARTIAL') + self.add(3, datetime(2026, 10, 1), tunnel=13) + self.add(4, None) + self.add(5, datetime(2026, 10, 2), strain=8) + self.add(6, datetime(2026, 10, 1), state='RETURNING') + self.add(7, datetime(2026, 10, 1), site=10) + + groups = await UsageFifoDao.queue(self.db, site_id=9, scope=TestBatchScope.formal()) + + self.assertEqual([4, 8], [group['strain_id'] for group in groups]) + main = groups[0] + self.assertEqual([2, 1], [pallet['pallet_id'] for pallet in main['pallets']]) + self.assertEqual((2, 1), (main['pallet_count'], main['unknown_arrival_count'])) + self.assertEqual(80, main['pallets'][0]['remaining_packages']) + self.assertEqual([5], [pallet['pallet_id'] for pallet in groups[1]['pallets']]) + candidate, unknown = await self.query() + self.assertEqual((main['pallets'][0]['pallet_id'], main['unknown_arrival_count']), (candidate['pallet_id'], unknown)) + + async def test_queue_limits_listed_pallets_but_counts_all_and_respects_test_scope(self) -> None: + for pallet_id in range(1, 6): + self.add(pallet_id, datetime(2026, 10, pallet_id)) + self.add(9, datetime(2026, 9, 1), batch=7) + + groups = await UsageFifoDao.queue(self.db, site_id=9, scope=TestBatchScope.formal(), limit_per_strain=2) + self.assertEqual(5, groups[0]['pallet_count']) + self.assertEqual([1, 2], [pallet['pallet_id'] for pallet in groups[0]['pallets']]) + test_groups = await UsageFifoDao.queue(self.db, site_id=9, scope=make_test_scope(7)) + self.assertEqual([9], [pallet['pallet_id'] for pallet in test_groups[0]['pallets']]) + + async def test_queue_service_checks_site_access_before_query(self) -> None: + with ( + patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场'))), + self.assertRaises(PermissionException), + ): + await UsageFifoService.queue(self.db, 9, MagicMock(), TestBatchScope.formal()) + self.db.execute.assert_not_awaited() + + self.add(1, datetime(2026, 10, 1)) + with ( + patch.object(AccessService, 'assert_site_access', new=AsyncMock()), + patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(site_id=9, test_batch_id=None))), + ): + result = await UsageFifoService.queue(self.db, 9, MagicMock(), TestBatchScope.formal()) + dumped = result.model_dump(by_alias=True) + self.assertEqual(9, dumped['siteId']) + self.assertEqual('P-1', dumped['strains'][0]['pallets'][0]['palletCode']) diff --git a/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py b/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py index f1a8b0e..2a6f9c1 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_usage_service.py @@ -30,6 +30,7 @@ from module_business.dao.access_dao import AccessDao from module_business.dao.inventory_dao import InventoryDao from module_business.dao.master_dao import MasterDao from module_business.dao.operation_dao import OperationDao +from module_business.dao.usage_summary_dao import UsageSummaryDao from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine, BizTunnel @@ -45,8 +46,11 @@ from module_business.entity.vo.operation_vo import ( PalletScanResponseModel, TunnelEntrySuccessResponseModel, UsageCreateModel, + UsageListItemModel, UsageModel, UsagePageQueryModel, + UsageSiteBoardModel, + UsageSiteOptionModel, UsageSuccessResponseModel, ) from module_business.service.access_service import AccessService @@ -381,6 +385,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase): with ( patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[11, 9, 9])), patch.object(OperationDao, 'get_usage_list', new=get_list), + patch.object(UsageSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})), ): result = await UsageService.get_usage_list_services(self.db, query, self.user, TestBatchScope.formal()) @@ -648,6 +653,8 @@ class TestUsageControllerContract(unittest.TestCase): ('/business/usage/list', 'get'), ('/business/usage/line-options', 'get'), ('/business/usage/tunnel-options', 'get'), + ('/business/usage/site-options', 'get'), + ('/business/usage/site-board', 'get'), ('/business/usage/enter-tunnel', 'post'), ('/business/usage/scan/{qr_token}', 'get'), ('/business/usage/{usage_id}', 'get'), @@ -674,7 +681,15 @@ class TestUsageControllerContract(unittest.TestCase): ) routes = self._routes() - self.assertEqual(PageResponseModel[UsageModel], routes[('/business/usage/list', 'GET')].response_model) + self.assertEqual(PageResponseModel[UsageListItemModel], routes[('/business/usage/list', 'GET')].response_model) + self.assertEqual( + DataResponseModel[list[UsageSiteOptionModel]], + routes[('/business/usage/site-options', 'GET')].response_model, + ) + self.assertEqual( + DataResponseModel[UsageSiteBoardModel], + routes[('/business/usage/site-board', 'GET')].response_model, + ) self.assertEqual( DataResponseModel[list[BusinessOptionModel]], routes[('/business/usage/line-options', 'GET')].response_model, @@ -717,6 +732,8 @@ class TestUsageControllerContract(unittest.TestCase): detail_index = paths.index('/business/usage/{usage_id}') self.assertLess(paths.index('/business/usage/line-options'), detail_index) self.assertLess(paths.index('/business/usage/tunnel-options'), detail_index) + self.assertLess(paths.index('/business/usage/site-options'), detail_index) + self.assertLess(paths.index('/business/usage/site-board'), detail_index) self.assertLess(paths.index('/business/usage/enter-tunnel'), detail_index) self.assertLess(paths.index('/business/usage/scan/{qr_token}'), detail_index) @@ -725,6 +742,8 @@ class TestUsageControllerContract(unittest.TestCase): ('/business/usage/list', 'GET'): 'business:usage:list', ('/business/usage/line-options', 'GET'): 'business:usage:query', ('/business/usage/tunnel-options', 'GET'): 'business:usage:query', + ('/business/usage/site-options', 'GET'): 'business:usage:list', + ('/business/usage/site-board', 'GET'): 'business:usage:query', ('/business/usage/enter-tunnel', 'POST'): 'business:usage:add', ('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query', ('/business/usage/{usage_id}', 'GET'): 'business:usage:query', diff --git a/shierfeng-fastapi-backend/tests/module_business/test_usage_summary.py b/shierfeng-fastapi-backend/tests/module_business/test_usage_summary.py new file mode 100644 index 0000000..90f110c --- /dev/null +++ b/shierfeng-fastapi-backend/tests/module_business/test_usage_summary.py @@ -0,0 +1,368 @@ +import unittest +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from sqlalchemy.dialects import mysql +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from exceptions.exception import PermissionException, ServiceException +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_business.dao.access_dao import AccessDao +from module_business.dao.master_dao import MasterDao +from module_business.dao.operation_dao import OperationDao +from module_business.dao.usage_summary_dao import UsageSummaryDao +from module_business.domain.test_batch_scope import TestBatchScope +from module_business.entity.vo.operation_vo import ( + UsageListItemModel, + UsagePageQueryModel, + UsageSiteBoardModel, + UsageSiteOptionModel, +) +from module_business.service.usage_service import UsageService +from utils.page_util import PageUtil + + +def compile_sql(statement: Any) -> str: + compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True}) + return ' '.join(str(compiled).split()).replace('%%', '%') + + +def make_test_scope(batch_id: int) -> TestBatchScope: + return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True) + + +def result_rows(rows: list[Any]) -> MagicMock: + result = MagicMock() + result.all.return_value = rows + return result + + +def result_one(row: tuple[Any, ...]) -> MagicMock: + result = MagicMock() + result.one.return_value = row + return result + + +def result_scalar(value: Any) -> MagicMock: + result = MagicMock() + result.scalar_one_or_none.return_value = value + return result + + +def admin_user() -> CurrentUserModel: + return CurrentUserModel.model_construct( + permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True) + ) + + +def normal_user() -> CurrentUserModel: + return CurrentUserModel.model_construct( + permissions=['business:usage:list'], + roles=['common'], + user=SimpleNamespace(user_id=5, user_name='zq', admin=False), + ) + + +class UsageListFilterTest(unittest.IsolatedAsyncioTestCase): + async def _capture(self, query: UsagePageQueryModel) -> str: + with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate: + await OperationDao.get_usage_list( + MagicMock(spec=AsyncSession), query, site_ids=[9], scope=make_test_scope(4) + ) + return compile_sql(paginate.await_args.args[1]) + + async def test_keyword_matches_usage_no_or_pallet_code_and_skip_filter_requires_reason(self) -> None: + sql = await self._capture(UsagePageQueryModel(keyword='TP-104', fifoSkipped=True)) + for fragment in ( + 'JOIN biz_pallet ON biz_pallet.pallet_id = biz_usage_record.pallet_id', + "biz_usage_record.usage_no LIKE '%TP-104%'", + "biz_pallet.pallet_code LIKE '%TP-104%'", + 'biz_usage_record.fifo_skip_reason IS NOT NULL', + 'biz_usage_record.site_id IN (9)', + 'biz_usage_record.test_batch_id = 4', + ): + self.assertIn(fragment, sql) + + async def test_plain_list_does_not_join_or_filter_skips(self) -> None: + sql = await self._capture(UsagePageQueryModel(fifoSkipped=False)) + self.assertNotIn('JOIN', sql) + self.assertNotIn('fifo_skip_reason IS NOT NULL', sql) + + def test_keyword_rejects_blank_value(self) -> None: + with self.assertRaises(ValueError): + UsagePageQueryModel(keyword=' ') + + +class UsageListSummaryDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_empty_ids_skip_database(self) -> None: + db = MagicMock(spec=AsyncSession) + self.assertEqual({}, await UsageSummaryDao.get_list_summaries(db, [], TestBatchScope.formal())) + db.execute.assert_not_called() + + async def test_names_and_skipped_pallet_come_from_same_scope_and_snapshot(self) -> None: + db = MagicMock(spec=AsyncSession) + snapshot = {'palletId': 100, 'palletCode': 'TP-10495', 'siteArrivedAt': '2026-09-10T08:30:00'} + db.execute = AsyncMock( + return_value=result_rows( + [ + (71, 'TP-10501', '南厂', 'JH-S-T03', '3 号隧道', '一号线', '赵强', snapshot), + (72, 'TP-10502', '南厂', None, None, None, None, None), + (73, 'TP-10503', '南厂', None, None, None, None, {'palletCode': ' '}), + ] + ) + ) + + summaries = await UsageSummaryDao.get_list_summaries(db, [73, 72, 71], make_test_scope(4)) + + self.assertEqual( + { + 'pallet_code': 'TP-10501', + 'site_name': '南厂', + 'tunnel_code': 'JH-S-T03', + 'tunnel_name': '3 号隧道', + 'line_name': '一号线', + 'operator_name': '赵强', + 'skipped_pallet_code': 'TP-10495', + }, + summaries[71], + ) + self.assertIsNone(summaries[72]['skipped_pallet_code']) + self.assertIsNone(summaries[73]['skipped_pallet_code']) + sql = compile_sql(db.execute.await_args.args[0]) + for fragment in ( + 'biz_usage_record.usage_id IN (71, 72, 73)', + 'biz_usage_record.test_batch_id = 4', + 'biz_pallet.test_batch_id = 4', + 'biz_customer_site.test_batch_id = 4', + 'biz_tunnel.test_batch_id = 4', + 'biz_production_line.test_batch_id = 4', + "sys_user.del_flag = '0'", + ): + self.assertIn(fragment, sql) + self.assertNotIn('trace_no', sql) + + +class UsageSiteOptionsDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_user_sites_are_enabled_and_carry_counts(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([(4, 'JH-S', '南厂', '金禾菌业', 4, None)])) + + options = await UsageSummaryDao.get_site_options(db, [5, 4, 4], make_test_scope(4)) + + self.assertEqual( + [ + { + 'id': 4, + 'code': 'JH-S', + 'name': '南厂', + 'customer_name': '金禾菌业', + 'tunnel_count': 4, + 'line_count': 0, + } + ], + options, + ) + sql = compile_sql(db.execute.await_args.args[0]) + for fragment in ( + 'biz_customer_site.site_id IN (4, 5)', + "biz_customer_site.status = '0'", + 'biz_customer_site.test_batch_id = 4', + "biz_tunnel.status = '0'", + 'biz_tunnel.test_batch_id = 4', + "biz_production_line.status = '0'", + ): + self.assertIn(fragment, sql) + + async def test_without_sites_matches_nothing_and_admin_is_unfiltered(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock(return_value=result_rows([])) + await UsageSummaryDao.get_site_options(db, [], TestBatchScope.formal()) + self.assertIn('false', compile_sql(db.execute.await_args.args[0]).lower()) + await UsageSummaryDao.get_site_options(db, None, TestBatchScope.formal()) + self.assertNotIn('biz_customer_site.site_id IN', compile_sql(db.execute.await_args.args[0])) + + +class UsageSiteBoardDaoTest(unittest.IsolatedAsyncioTestCase): + async def test_board_combines_stock_today_usage_tunnels_and_skips(self) -> None: + db = MagicMock(spec=AsyncSession) + db.execute = AsyncMock( + side_effect=[ + result_scalar('金禾菌业'), + result_rows([('AT_SITE_UNUSED', 22, 2200), ('AT_SITE_PARTIAL', 4, 205), ('AT_SITE_EMPTY', 8, 0)]), + result_rows([(4, '一号线', 3, 120), (None, None, 1, 20), (5, '二号线', 2, 200)]), + result_one((1, 3)), + result_rows( + [ + (2, 'JH-S-T01', '1 号隧道', True, datetime(2026, 9, 7, 7, 59)), + (3, 'JH-S-T02', '2 号隧道', False, datetime(2026, 9, 1)), + ] + ), + result_rows([(2, 1, 75)]), + result_rows([(2, datetime(2026, 10, 17, 10, 55), '一号线')]), + result_rows([(4, 'JH-S-L1', '一号线'), (5, 'JH-S-L2', '二号线')]), + ] + ) + site = SimpleNamespace(site_id=4, site_code='JH-S', site_name='南厂', customer_id=3) + + board = await UsageSummaryDao.get_site_board( + db, site, make_test_scope(4), datetime(2026, 10, 18), datetime(2026, 10, 12) + ) + + self.assertEqual( + (26, 2405, 22, 4, 8), + ( + board['pallet_count'], + board['package_sum'], + board['unused_pallet_count'], + board['partial_pallet_count'], + board['empty_pallet_count'], + ), + ) + self.assertEqual((6, 340), (board['today_usage_count'], board['today_used_packages'])) + self.assertEqual([200, 120, 20], [row['used_packages'] for row in board['today_by_line']]) + self.assertEqual((2, 1), (board['tunnel_count'], board['active_tunnel_count'])) + self.assertEqual((1, 3), (board['today_fifo_skip_count'], board['week_fifo_skip_count'])) + first, idle = board['tunnels'] + self.assertEqual( + (1, 75, '一号线'), (first['pallet_count'], first['remaining_packages'], first['last_line_name']) + ) + self.assertEqual( + (False, 0, None, None), (idle['in_use'], idle['pallet_count'], idle['started_at'], idle['last_used_at']) + ) + self.assertNotIn('trace_no', board['tunnels'][0]) + UsageSiteBoardModel.model_validate(board) + + statements = [compile_sql(call.args[0]) for call in db.execute.await_args_list] + _, stock_sql, today_sql, skip_sql, tunnel_sql, load_sql, last_sql, line_sql = statements + self.assertIn('biz_pallet_inventory.site_id = 4', stock_sql) + self.assertIn('biz_pallet_inventory.warehouse_id IS NULL', stock_sql) + self.assertIn("biz_usage_record.occurred_at >= '2026-10-18 00:00:00'", today_sql) + self.assertIn("biz_usage_record.occurred_at >= '2026-10-12 00:00:00'", skip_sql) + self.assertIn('biz_usage_record.fifo_skip_reason IS NOT NULL', skip_sql) + # 只暴露隧道是否在用,不返回内部溯源号的值 + self.assertIn('biz_tunnel.current_trace_no IS NOT NULL AS in_use', tunnel_sql) + self.assertIn("biz_tunnel.status = '0'", tunnel_sql) + self.assertIn('biz_pallet_inventory.remaining_packages > 0', load_sql) + self.assertIn('max(biz_usage_record.usage_id)', last_sql) + for sql in statements[1:]: + self.assertRegex(sql, r'test_batch_id = 4') + self.assertIn("biz_production_line.status = '0'", line_sql) + + +class UsageServiceSummaryTest(unittest.IsolatedAsyncioTestCase): + async def test_list_rows_carry_names_in_camel_case(self) -> None: + record = { + 'usage_id': 71, + 'usage_no': 'US-1', + 'pallet_id': 501, + 'active_filling_id': 9, + 'site_id': 4, + 'tunnel_id': 2, + 'used_packages': 20, + 'remaining_packages_after': 80, + 'idempotency_key': 'k', + 'request_hash': 'h', + 'operator_id': 2, + 'occurred_at': datetime(2026, 10, 17, 10, 55), + 'fifo_skip_reason': '建议托盘外包装破损', + } + page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False) + summary = { + 71: { + 'pallet_code': 'TP-10501', + 'site_name': '南厂', + 'tunnel_code': 'JH-S-T01', + 'tunnel_name': '1 号隧道', + 'line_name': '一号线', + 'operator_name': '赵强', + 'skipped_pallet_code': 'TP-10495', + } + } + with ( + patch.object(OperationDao, 'get_usage_list', new=AsyncMock(return_value=page)), + patch.object(UsageSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries, + ): + result = await UsageService.get_usage_list_services( + MagicMock(spec=AsyncSession), UsagePageQueryModel(), admin_user(), TestBatchScope.formal() + ) + + self.assertEqual([71], summaries.await_args.args[1]) + self.assertIsInstance(result.rows[0], UsageListItemModel) + dumped = result.rows[0].model_dump(by_alias=True) + self.assertEqual( + ('TP-10501', '1 号隧道', '一号线', 'TP-10495'), + (dumped['palletCode'], dumped['tunnelName'], dumped['lineName'], dumped['skippedPalletCode']), + ) + + async def test_site_options_use_site_scope(self) -> None: + rows = [ + {'id': 4, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业', 'tunnel_count': 4, 'line_count': 3} + ] + with ( + patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[5, 4, 4])), + patch.object(UsageSummaryDao, 'get_site_options', new=AsyncMock(return_value=rows)) as get_options, + ): + result = await UsageService.get_site_options_services( + MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal() + ) + + self.assertEqual([4, 5], get_options.await_args.args[1]) + self.assertIsInstance(result[0], UsageSiteOptionModel) + self.assertEqual(4, result[0].model_dump(by_alias=True)['tunnelCount']) + + async def test_board_requires_site_access_before_reading(self) -> None: + get_board = AsyncMock() + with ( + patch.object(AccessDao, 'user_has_site', new=AsyncMock(return_value=False)), + patch.object(UsageSummaryDao, 'get_site_board', new=get_board), + self.assertRaises(PermissionException), + ): + await UsageService.get_site_board_services( + MagicMock(spec=AsyncSession), 7, normal_user(), TestBatchScope.formal() + ) + get_board.assert_not_awaited() + + async def test_board_rejects_missing_site_and_passes_today_and_week_bounds(self) -> None: + with ( + patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=None)), + self.assertRaises(ServiceException) as raised, + ): + await UsageService.get_site_board_services( + MagicMock(spec=AsyncSession), 4, admin_user(), TestBatchScope.formal() + ) + self.assertEqual('客户现场不存在', raised.exception.message) + + site = SimpleNamespace(site_id=4, test_batch_id=None) + board = { + 'site_id': 4, + 'pallet_count': 0, + 'package_sum': 0, + 'unused_pallet_count': 0, + 'partial_pallet_count': 0, + 'empty_pallet_count': 0, + 'today_usage_count': 0, + 'today_used_packages': 0, + 'tunnel_count': 0, + 'active_tunnel_count': 0, + 'today_fifo_skip_count': 0, + 'week_fifo_skip_count': 0, + } + with ( + patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=site)), + patch.object(UsageSummaryDao, 'get_site_board', new=AsyncMock(return_value=board)) as get_board, + ): + result = await UsageService.get_site_board_services( + MagicMock(spec=AsyncSession), 4, admin_user(), TestBatchScope.formal() + ) + + self.assertIsInstance(result, UsageSiteBoardModel) + today_start, week_start = get_board.await_args.args[3:5] + self.assertEqual((0, 0), (today_start.hour, today_start.minute)) + self.assertEqual(6, (today_start - week_start).days) + + +if __name__ == '__main__': + unittest.main() diff --git a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py index 3fb5d65..cd87c0a 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py @@ -30,31 +30,52 @@ from module_business.entity.vo.master_vo import ( from module_business.entity.vo.operation_vo import ( BusinessOptionModel, ConfirmCommandModel, + FillingBatchDetailModel, + FillingBatchDetailQueryModel, + FillingBatchModel, + FillingBatchPageQueryModel, + FillingBatchRecordModel, + FillingBatchStateCountModel, FillingCreateModel, + FillingListItemModel, FillingModel, + FillingOverviewModel, FillingPageQueryModel, InboundCreateModel, InboundItemModel, + InboundListItemModel, InboundModel, + InboundOverviewModel, InboundPageQueryModel, InboundScanQueryModel, InboundUpdateModel, + InboundWarehouseCountModel, PalletScanResponseModel, ReturnConfirmModel, ReturnItemModel, + ReturnListItemModel, ReturnModel, + ReturnOverviewModel, ReturnPageQueryModel, ReturnRequestModel, ReturnScanQueryModel, + ReturnSiteOptionModel, ShipmentCreateModel, ShipmentItemModel, + ShipmentListItemModel, ShipmentModel, ShipmentPageQueryModel, ShipmentScanQueryModel, + ShipmentStrainSummaryModel, ShipmentUpdateModel, + UsageBoardTunnelModel, UsageCreateModel, + UsageLinePackagesModel, + UsageListItemModel, UsageModel, UsagePageQueryModel, + UsageSiteBoardModel, + UsageSiteOptionModel, ) from module_business.entity.vo.report_vo import ( ConsumptionReportModel, @@ -170,7 +191,7 @@ MASTER_MODEL_FIELDS = { }, PalletCreateModel: {'pallet_code', 'capacity_packages', 'remark'}, PalletEditModel: {'pallet_id', 'capacity_packages', 'asset_status', 'remark'}, - PalletPageQueryModel: {'page_num', 'page_size', 'pallet_code', 'asset_status', 'ids'}, + PalletPageQueryModel: {'page_num', 'page_size', 'pallet_code', 'asset_status', 'flow_group', 'ids'}, DeletePalletModel: {'pallet_ids'}, UserAssignModel: {'user_ids'}, } @@ -195,7 +216,15 @@ OPERATION_MODEL_FIELDS = { UsageCreateModel: {'qr_token', 'tunnel_id', 'line_id', 'used_packages', 'idempotency_key', 'fifo_skip_reason'}, ReturnRequestModel: {'source_site_id', 'target_warehouse_id', 'pallet_ids', 'idempotency_key'}, ReturnConfirmModel: {'scanned_pallet_ids', 'idempotency_key'}, - InboundItemModel: {'inbound_item_id', 'inbound_id', 'pallet_id', 'create_by', 'create_time'}, + InboundItemModel: { + 'inbound_item_id', + 'inbound_id', + 'pallet_id', + 'pallet_code', + 'flow_state', + 'create_by', + 'create_time', + }, InboundModel: { 'inbound_id', 'inbound_no', @@ -212,6 +241,38 @@ OPERATION_MODEL_FIELDS = { 'remark', 'items', }, + InboundListItemModel: { + 'inbound_id', + 'inbound_no', + 'warehouse_id', + 'status', + 'confirm_idempotency_key', + 'confirm_request_hash', + 'confirmed_by', + 'confirmed_at', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + 'items', + 'warehouse_name', + 'creator_name', + 'confirmer_name', + 'pallet_count', + }, + InboundWarehouseCountModel: {'warehouse_id', 'warehouse_name', 'pallet_count'}, + InboundOverviewModel: { + 'draft_count', + 'draft_pallet_count', + 'oldest_draft_at', + 'month_start', + 'month_confirmed_count', + 'month_confirmed_pallet_count', + 'unstocked_pallet_count', + 'empty_pallet_count', + 'empty_by_warehouse', + }, FillingModel: { 'filling_id', 'filling_no', @@ -237,6 +298,88 @@ OPERATION_MODEL_FIELDS = { 'update_time', 'remark', }, + FillingListItemModel: { + 'filling_id', + 'filling_no', + 'pallet_id', + 'warehouse_id', + 'strain_id', + 'strain_code_snapshot', + 'strain_name_snapshot', + 'batch_no', + 'production_date', + 'expiry_date', + 'package_spec', + 'initial_packages', + 'active_slot', + 'closed_at', + 'idempotency_key', + 'request_hash', + 'operator_id', + 'operated_at', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + 'pallet_code', + 'warehouse_name', + 'operator_name', + }, + FillingBatchModel: { + 'strain_id', + 'strain_code', + 'strain_name', + 'batch_no', + 'production_date', + 'expiry_date', + 'package_spec', + 'pallet_count', + 'package_sum', + 'last_operated_at', + 'last_operator_name', + }, + FillingBatchStateCountModel: {'state', 'pallet_count'}, + FillingBatchRecordModel: { + 'filling_id', + 'filling_no', + 'pallet_id', + 'pallet_code', + 'warehouse_name', + 'operator_name', + 'operated_at', + }, + FillingBatchDetailModel: { + 'strain_id', + 'strain_code', + 'strain_name', + 'batch_no', + 'production_date', + 'expiry_date', + 'package_spec', + 'pallet_count', + 'package_sum', + 'last_operated_at', + 'last_operator_name', + 'distribution', + 'recent', + }, + FillingOverviewModel: { + 'today_pallet_count', + 'today_package_sum', + 'today_batch_count', + 'month_start', + 'month_pallet_count', + 'month_batch_count', + 'month_top_strains', + 'empty_pallet_count', + 'empty_by_warehouse', + 'near_expiry_days', + 'near_expiry_pallet_count', + 'near_expiry_batch_count', + 'nearest_expiry_batch_no', + 'nearest_expiry_date', + }, ShipmentItemModel: { 'shipment_item_id', 'shipment_id', @@ -244,6 +387,10 @@ OPERATION_MODEL_FIELDS = { 'active_filling_id', 'package_qty', 'remaining_packages', + 'pallet_code', + 'strain_code', + 'strain_name', + 'batch_no', 'create_by', 'create_time', }, @@ -267,6 +414,37 @@ OPERATION_MODEL_FIELDS = { 'remark', 'items', }, + ShipmentStrainSummaryModel: { + 'strain_name', + 'pallet_count', + }, + ShipmentListItemModel: { + 'shipment_id', + 'shipment_no', + 'source_warehouse_id', + 'target_site_id', + 'status', + 'confirm_idempotency_key', + 'confirm_request_hash', + 'confirmed_by', + 'confirmed_at', + 'receipt_state', + 'received_by', + 'received_at', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + 'items', + 'source_warehouse_name', + 'target_site_name', + 'customer_name', + 'creator_name', + 'pallet_count', + 'package_qty', + 'strains', + }, UsageModel: { 'usage_id', 'fifo_skip_reason', @@ -285,6 +463,63 @@ OPERATION_MODEL_FIELDS = { 'operator_id', 'occurred_at', }, + UsageListItemModel: { + 'usage_id', + 'fifo_skip_reason', + 'usage_no', + 'pallet_id', + 'active_filling_id', + 'site_id', + 'tunnel_id', + 'trace_no', + 'batch_no_snapshot', + 'line_id', + 'used_packages', + 'remaining_packages_after', + 'idempotency_key', + 'request_hash', + 'operator_id', + 'occurred_at', + 'pallet_code', + 'site_name', + 'tunnel_code', + 'tunnel_name', + 'line_name', + 'operator_name', + 'skipped_pallet_code', + }, + UsageBoardTunnelModel: { + 'tunnel_id', + 'tunnel_code', + 'tunnel_name', + 'in_use', + 'pallet_count', + 'remaining_packages', + 'started_at', + 'last_used_at', + 'last_line_name', + }, + UsageLinePackagesModel: {'line_id', 'line_name', 'used_packages'}, + UsageSiteBoardModel: { + 'site_id', + 'site_code', + 'site_name', + 'customer_name', + 'pallet_count', + 'package_sum', + 'unused_pallet_count', + 'partial_pallet_count', + 'empty_pallet_count', + 'today_usage_count', + 'today_used_packages', + 'today_by_line', + 'tunnel_count', + 'active_tunnel_count', + 'today_fifo_skip_count', + 'week_fifo_skip_count', + 'tunnels', + 'lines', + }, ReturnItemModel: { 'return_item_id', 'return_id', @@ -294,6 +529,8 @@ OPERATION_MODEL_FIELDS = { 'trace_no_snapshot', 'batch_no_snapshot', 'remaining_packages_snapshot', + 'pallet_code', + 'tunnel_name', 'create_by', 'create_time', }, @@ -318,6 +555,48 @@ OPERATION_MODEL_FIELDS = { 'remark', 'items', }, + ReturnListItemModel: { + 'return_id', + 'return_no', + 'source_site_id', + 'target_warehouse_id', + 'status', + 'request_idempotency_key', + 'request_hash', + 'confirm_idempotency_key', + 'confirm_request_hash', + 'requested_by', + 'requested_at', + 'confirmed_by', + 'confirmed_at', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + 'items', + 'source_site_name', + 'customer_name', + 'target_warehouse_name', + 'requester_name', + 'confirmer_name', + 'pallet_count', + 'package_sum', + 'filled_count', + }, + ReturnOverviewModel: { + 'pending_count', + 'pending_pallet_count', + 'pending_filled_count', + 'pending_package_sum', + 'oldest_pending_at', + 'month_start', + 'month_confirmed_count', + 'month_confirmed_pallet_count', + 'month_filled_pallet_count', + 'month_package_sum', + 'month_avg_confirm_hours', + }, InboundPageQueryModel: { 'page_num', 'page_size', @@ -331,6 +610,7 @@ OPERATION_MODEL_FIELDS = { 'page_num', 'page_size', 'filling_no', + 'keyword', 'warehouse_id', 'strain_id', 'batch_no', @@ -338,13 +618,24 @@ OPERATION_MODEL_FIELDS = { 'begin_time', 'end_time', }, + FillingBatchPageQueryModel: { + 'page_num', + 'page_size', + 'keyword', + 'strain_id', + 'begin_time', + 'end_time', + }, + FillingBatchDetailQueryModel: {'strain_id', 'batch_no'}, ShipmentPageQueryModel: { 'page_num', 'page_size', 'shipment_no', + 'keyword', 'source_warehouse_id', 'target_site_id', 'status', + 'receipt_state', 'begin_time', 'end_time', }, @@ -352,6 +643,8 @@ OPERATION_MODEL_FIELDS = { 'page_num', 'page_size', 'usage_no', + 'keyword', + 'fifo_skipped', 'site_id', 'tunnel_id', 'trace_no', @@ -364,6 +657,7 @@ OPERATION_MODEL_FIELDS = { 'page_num', 'page_size', 'return_no', + 'keyword', 'source_site_id', 'target_warehouse_id', 'status', @@ -374,6 +668,8 @@ OPERATION_MODEL_FIELDS = { ShipmentScanQueryModel: {'source_warehouse_id', 'target_site_id'}, ReturnScanQueryModel: {'mode', 'return_id'}, BusinessOptionModel: {'id', 'code', 'name'}, + ReturnSiteOptionModel: {'id', 'code', 'name', 'customer_name'}, + UsageSiteOptionModel: {'id', 'code', 'name', 'customer_name', 'tunnel_count', 'line_count'}, PalletScanResponseModel: { 'pallet_id', 'pallet_code', @@ -726,6 +1022,7 @@ WRITE_MODELS = ( OPERATION_PAGE_QUERY_MODELS = ( InboundPageQueryModel, FillingPageQueryModel, + FillingBatchPageQueryModel, ShipmentPageQueryModel, UsagePageQueryModel, ReturnPageQueryModel,