Feat: 后端新增基础资料与业务管理只读汇总接口

为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情
返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。

- 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转,
  托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级
  资料选项接口;Excel 导入支持 preview 只校验不写入
- 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和
  菌种构成;详情明细补充托盘号、菌种、批次
- 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary
- 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道;
  新增 /summary、/site-options
- 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、
  /batches、/batches/detail
- 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号;
  新增 /site-options、/site-board 以及 usage-fifo 的 /queue
- 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致
- 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 05:03:10 -07:00
co-authored by Claude Opus 5.5
parent 80a71b48ce
commit 3050ccdc4e
51 changed files with 6545 additions and 103 deletions
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope 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.entity.vo.master_vo import CustomerModel, CustomerPageQueryModel, DeleteCustomerModel
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
customer_controller = APIRouterPro( customer_controller = APIRouterPro(
@@ -27,7 +29,7 @@ customer_controller = APIRouterPro(
'/list', '/list',
summary='获取客户分页列表接口', summary='获取客户分页列表接口',
description='用于获取客户分页列表', description='用于获取客户分页列表',
response_model=PageResponseModel[CustomerModel], response_model=PageResponseModel[CustomerListItemModel],
dependencies=[UserInterfaceAuthDependency('master:customer:list')], dependencies=[UserInterfaceAuthDependency('master:customer:list')],
) )
async def get_master_customer_list( async def get_master_customer_list(
@@ -37,6 +39,9 @@ async def get_master_customer_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
customer_page_query_result = await MasterService.list_customer(query_db, customer_page_query, scope, is_page=True) 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) return ResponseUtil.success(model_content=customer_page_query_result)
@@ -45,7 +50,7 @@ async def get_master_customer_list(
'/{customer_id}', '/{customer_id}',
summary='获取客户详情接口', summary='获取客户详情接口',
description='用于获取指定客户的详细信息', description='用于获取指定客户的详细信息',
response_model=DataResponseModel[CustomerModel], response_model=DataResponseModel[CustomerDetailModel],
dependencies=[UserInterfaceAuthDependency('master:customer:query')], dependencies=[UserInterfaceAuthDependency('master:customer:query')],
) )
async def query_detail_master_customer( async def query_detail_master_customer(
@@ -55,6 +60,7 @@ async def query_detail_master_customer(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
customer_detail_result = await MasterService.detail_customer(query_db, customer_id, scope) 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) return ResponseUtil.success(data=customer_detail_result)
@@ -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.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
FillingBatchDetailModel,
FillingBatchDetailQueryModel,
FillingBatchModel,
FillingBatchPageQueryModel,
FillingCreateModel, FillingCreateModel,
FillingListItemModel,
FillingModel, FillingModel,
FillingOverviewModel,
FillingPageQueryModel, FillingPageQueryModel,
PalletScanResponseModel, PalletScanResponseModel,
) )
@@ -34,7 +40,7 @@ filling_controller = APIRouterPro(
'/list', '/list',
summary='获取托盘装填记录分页列表接口', summary='获取托盘装填记录分页列表接口',
description='按当前用户仓库数据范围获取托盘装填记录分页列表', description='按当前用户仓库数据范围获取托盘装填记录分页列表',
response_model=PageResponseModel[FillingModel], response_model=PageResponseModel[FillingListItemModel],
dependencies=[UserInterfaceAuthDependency('business:filling:list')], dependencies=[UserInterfaceAuthDependency('business:filling:list')],
) )
async def get_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) 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( @filling_controller.get(
'/strain-options', '/strain-options',
summary='获取装填菌种选项接口', summary='获取装填菌种选项接口',
@@ -17,7 +17,9 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
ConfirmCommandModel, ConfirmCommandModel,
InboundCreateModel, InboundCreateModel,
InboundListItemModel,
InboundModel, InboundModel,
InboundOverviewModel,
InboundPageQueryModel, InboundPageQueryModel,
InboundScanQueryModel, InboundScanQueryModel,
InboundUpdateModel, InboundUpdateModel,
@@ -37,7 +39,7 @@ inbound_controller = APIRouterPro(
'/list', '/list',
summary='获取首次入库单分页列表接口', summary='获取首次入库单分页列表接口',
description='按当前用户仓库数据范围获取首次入库单分页列表', description='按当前用户仓库数据范围获取首次入库单分页列表',
response_model=PageResponseModel[InboundModel], response_model=PageResponseModel[InboundListItemModel],
dependencies=[UserInterfaceAuthDependency('business:inbound:list')], dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
) )
async def get_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) 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( @inbound_controller.get(
'/warehouse-options', '/warehouse-options',
summary='获取首次入库仓库选项接口', summary='获取首次入库仓库选项接口',
@@ -14,18 +14,40 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope 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.entity.vo.master_vo import DeleteLineModel, LineModel, LinePageQueryModel
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
line_controller = APIRouterPro(prefix='/master/line', tags=['基础资料-生产线管理'], dependencies=[PreAuthDependency()]) 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( @line_controller.get(
'/list', '/list',
summary='获取生产线分页列表接口', summary='获取生产线分页列表接口',
description='用于获取生产线分页列表', description='用于获取生产线分页列表',
response_model=PageResponseModel[LineModel], response_model=PageResponseModel[LineListItemModel],
dependencies=[UserInterfaceAuthDependency('master:line:list')], dependencies=[UserInterfaceAuthDependency('master:line:list')],
) )
async def get_master_line_list( async def get_master_line_list(
@@ -35,6 +57,7 @@ async def get_master_line_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
line_page_query_result = await MasterService.list_line(query_db, line_page_query, scope, is_page=True) 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) return ResponseUtil.success(model_content=line_page_query_result)
@@ -88,7 +88,14 @@ async def _import_excel(
db: AsyncSession, db: AsyncSession,
current_user: CurrentUserModel, current_user: CurrentUserModel,
scope: TestBatchScope, scope: TestBatchScope,
preview: bool = False,
) -> Response: ) -> 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( result = await MasterExcelService.import_resource(
db, resource, file, update_support, current_user, scope db, resource, file, update_support, current_user, scope
) )
@@ -127,8 +134,9 @@ async def import_master_strain_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> 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( @strain_excel_controller.post(
@@ -176,8 +184,9 @@ async def import_master_customer_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> 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( @customer_excel_controller.post(
@@ -225,8 +234,9 @@ async def import_master_site_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> 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( @site_excel_controller.post(
@@ -274,8 +284,9 @@ async def import_master_line_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> 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( @line_excel_controller.post(
@@ -323,8 +334,9 @@ async def import_master_warehouse_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> 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( @warehouse_excel_controller.post(
@@ -372,8 +384,9 @@ async def import_master_pallet_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> 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( @pallet_excel_controller.post(
@@ -421,8 +434,9 @@ async def import_master_tunnel_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> 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( @tunnel_excel_controller.post(
@@ -13,13 +13,14 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope 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 ( from module_business.entity.vo.master_vo import (
DeletePalletModel, DeletePalletModel,
PalletCreateModel, PalletCreateModel,
PalletEditModel, PalletEditModel,
PalletModel,
PalletPageQueryModel, PalletPageQueryModel,
) )
from module_business.service.master_summary_service import MasterSummaryService
from module_business.service.pallet_service import PalletService from module_business.service.pallet_service import PalletService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -32,7 +33,7 @@ pallet_controller = APIRouterPro(
'/list', '/list',
summary='获取托盘分页列表接口', summary='获取托盘分页列表接口',
description='用于获取托盘分页列表', description='用于获取托盘分页列表',
response_model=PageResponseModel[PalletModel], response_model=PageResponseModel[PalletListItemModel],
dependencies=[UserInterfaceAuthDependency('master:pallet:list')], dependencies=[UserInterfaceAuthDependency('master:pallet:list')],
) )
async def get_master_pallet_list( async def get_master_pallet_list(
@@ -42,6 +43,7 @@ async def get_master_pallet_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
result = await PalletService.list_pallet(query_db, pallet_page_query, scope, is_page=True) 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) return ResponseUtil.success(model_content=result)
@@ -67,7 +69,7 @@ async def query_master_pallet_qrcode(
'/{pallet_id}', '/{pallet_id}',
summary='获取托盘详情接口', summary='获取托盘详情接口',
description='用于获取指定托盘的详细信息', description='用于获取指定托盘的详细信息',
response_model=DataResponseModel[PalletModel], response_model=DataResponseModel[PalletDetailModel],
dependencies=[UserInterfaceAuthDependency('master:pallet:query')], dependencies=[UserInterfaceAuthDependency('master:pallet:query')],
) )
async def query_detail_master_pallet( async def query_detail_master_pallet(
@@ -77,6 +79,7 @@ async def query_detail_master_pallet(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
result = await PalletService.detail_pallet(query_db, pallet_id, scope) result = await PalletService.detail_pallet(query_db, pallet_id, scope)
result = await MasterSummaryService.pallet_detail(query_db, result, scope)
return ResponseUtil.success(data=result) return ResponseUtil.success(data=result)
@@ -19,10 +19,13 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
PalletScanResponseModel, PalletScanResponseModel,
ReturnConfirmModel, ReturnConfirmModel,
ReturnListItemModel,
ReturnModel, ReturnModel,
ReturnOverviewModel,
ReturnPageQueryModel, ReturnPageQueryModel,
ReturnRequestModel, ReturnRequestModel,
ReturnScanQueryModel, ReturnScanQueryModel,
ReturnSiteOptionModel,
) )
from module_business.service.return_service import ReturnService from module_business.service.return_service import ReturnService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -38,7 +41,7 @@ return_controller = APIRouterPro(
'/list', '/list',
summary='获取托盘退回单分页列表接口', summary='获取托盘退回单分页列表接口',
description='按当前用户来源现场或目标仓库数据范围获取退回单', description='按当前用户来源现场或目标仓库数据范围获取退回单',
response_model=PageResponseModel[ReturnModel], response_model=PageResponseModel[ReturnListItemModel],
dependencies=[UserInterfaceAuthDependency('business:return:list')], dependencies=[UserInterfaceAuthDependency('business:return:list')],
) )
async def get_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 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( @return_controller.get(
'/warehouse-options', '/warehouse-options',
summary='获取退回目标仓库选项接口', summary='获取退回目标仓库选项接口',
@@ -18,6 +18,7 @@ from module_business.entity.vo.operation_vo import (
ConfirmCommandModel, ConfirmCommandModel,
PalletScanResponseModel, PalletScanResponseModel,
ShipmentCreateModel, ShipmentCreateModel,
ShipmentListItemModel,
ShipmentModel, ShipmentModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
ShipmentScanQueryModel, ShipmentScanQueryModel,
@@ -39,7 +40,7 @@ shipment_controller = APIRouterPro(
'/list', '/list',
summary='获取整托发货单分页列表接口', summary='获取整托发货单分页列表接口',
description='按当前用户仓库与现场双数据范围获取整托发货单', description='按当前用户仓库与现场双数据范围获取整托发货单',
response_model=PageResponseModel[ShipmentModel], response_model=PageResponseModel[ShipmentListItemModel],
dependencies=[UserInterfaceAuthDependency('business:shipment:list')], dependencies=[UserInterfaceAuthDependency('business:shipment:list')],
) )
async def get_business_shipment_list( async def get_business_shipment_list(
@@ -14,6 +14,7 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope 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 ( from module_business.entity.vo.master_vo import (
BusinessUserOptionModel, BusinessUserOptionModel,
DeleteSiteModel, DeleteSiteModel,
@@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import (
UserAssignModel, UserAssignModel,
) )
from module_business.service.master_service import MasterService 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 module_business.service.pallet_service import PalletService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -31,6 +33,26 @@ ASSIGN_CURRENT_USER = CurrentUserDependency
site_controller = APIRouterPro( site_controller = APIRouterPro(
prefix='/master/site', tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()] 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( site_user_controller = APIRouterPro(
prefix='/master/site', tags=['基础资料-客户现场用户分配'], dependencies=[PreAuthDependency()] prefix='/master/site', tags=['基础资料-客户现场用户分配'], dependencies=[PreAuthDependency()]
) )
@@ -77,7 +99,7 @@ async def assign_master_site_users(
'/list', '/list',
summary='获取客户现场分页列表接口', summary='获取客户现场分页列表接口',
description='用于获取客户现场分页列表', description='用于获取客户现场分页列表',
response_model=PageResponseModel[SiteModel], response_model=PageResponseModel[SiteListItemModel],
dependencies=[UserInterfaceAuthDependency('master:site:list')], dependencies=[UserInterfaceAuthDependency('master:site:list')],
) )
async def get_master_site_list( async def get_master_site_list(
@@ -87,6 +109,7 @@ async def get_master_site_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
site_page_query_result = await MasterService.list_site(query_db, site_page_query, scope, is_page=True) 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) return ResponseUtil.success(model_content=site_page_query_result)
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope 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.entity.vo.master_vo import DeleteStrainModel, StrainModel, StrainPageQueryModel
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
strain_controller = APIRouterPro( strain_controller = APIRouterPro(
@@ -27,7 +29,7 @@ strain_controller = APIRouterPro(
'/list', '/list',
summary='获取菌种分页列表接口', summary='获取菌种分页列表接口',
description='用于获取菌种分页列表', description='用于获取菌种分页列表',
response_model=PageResponseModel[StrainModel], response_model=PageResponseModel[StrainListItemModel],
dependencies=[UserInterfaceAuthDependency('master:strain:list')], dependencies=[UserInterfaceAuthDependency('master:strain:list')],
) )
async def get_master_strain_list( async def get_master_strain_list(
@@ -37,6 +39,9 @@ async def get_master_strain_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
strain_page_query_result = await MasterService.list_strain(query_db, strain_page_query, scope, is_page=True) 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) return ResponseUtil.success(model_content=strain_page_query_result)
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope 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.entity.vo.master_vo import DeleteTunnelModel, TunnelModel, TunnelPageQueryModel
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
tunnel_controller = APIRouterPro( tunnel_controller = APIRouterPro(
@@ -23,13 +25,33 @@ tunnel_controller = APIRouterPro(
tags=['基础资料-隧道管理'], tags=['基础资料-隧道管理'],
dependencies=[PreAuthDependency()], 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( @tunnel_controller.get(
'/list', '/list',
summary='获取隧道分页列表接口', summary='获取隧道分页列表接口',
description='用于获取客户现场隧道分页列表', description='用于获取客户现场隧道分页列表',
response_model=PageResponseModel[TunnelModel], response_model=PageResponseModel[TunnelListItemModel],
dependencies=[UserInterfaceAuthDependency('master:tunnel:list')], dependencies=[UserInterfaceAuthDependency('master:tunnel:list')],
) )
async def get_master_tunnel_list( async def get_master_tunnel_list(
@@ -39,6 +61,7 @@ async def get_master_tunnel_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
result = await MasterService.list_tunnel(query_db, tunnel_page_query, scope, is_page=True) 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) return ResponseUtil.success(model_content=result)
@@ -21,8 +21,11 @@ from module_business.entity.vo.operation_vo import (
TunnelEntryCreateModel, TunnelEntryCreateModel,
TunnelEntrySuccessResponseModel, TunnelEntrySuccessResponseModel,
UsageCreateModel, UsageCreateModel,
UsageListItemModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSiteBoardModel,
UsageSiteOptionModel,
UsageSuccessResponseModel, UsageSuccessResponseModel,
) )
from module_business.service.usage_service import UsageService from module_business.service.usage_service import UsageService
@@ -39,7 +42,7 @@ usage_controller = APIRouterPro(
'/list', '/list',
summary='获取客户现场使用记录分页列表接口', summary='获取客户现场使用记录分页列表接口',
description='按当前用户客户现场数据范围获取使用记录', description='按当前用户客户现场数据范围获取使用记录',
response_model=PageResponseModel[UsageModel], response_model=PageResponseModel[UsageListItemModel],
dependencies=[UserInterfaceAuthDependency('business:usage:list')], dependencies=[UserInterfaceAuthDependency('business:usage:list')],
) )
async def get_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) 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( @usage_controller.post(
'/enter-tunnel', '/enter-tunnel',
summary='扫码入隧接口', summary='扫码入隧接口',
@@ -13,18 +13,22 @@ from common.vo import DataResponseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope 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 module_business.service.usage_fifo_service import UsageFifoService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
usage_fifo_controller = APIRouterPro( usage_fifo_controller = APIRouterPro(
prefix='/business/usage-fifo', prefix='/business/usage-fifo',
tags=['业务管理-客户现场使用'], 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( async def get_usage_fifo_recommendation(
request: Request, request: Request,
qr_token: Annotated[str, Query(alias='qrToken', min_length=1, max_length=128, pattern=r'\S')], 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, query_db, qr_token, current_user, test_scope, tunnel_id=tunnel_id,
) )
return ResponseUtil.success(data=result) 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)
@@ -14,6 +14,7 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope 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 ( from module_business.entity.vo.master_vo import (
BusinessUserOptionModel, BusinessUserOptionModel,
DeleteWarehouseModel, DeleteWarehouseModel,
@@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import (
WarehousePageQueryModel, WarehousePageQueryModel,
) )
from module_business.service.master_service import MasterService 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 module_business.service.pallet_service import PalletService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -77,7 +79,7 @@ async def assign_master_warehouse_users(
'/list', '/list',
summary='获取仓库分页列表接口', summary='获取仓库分页列表接口',
description='用于获取仓库分页列表', description='用于获取仓库分页列表',
response_model=PageResponseModel[WarehouseModel], response_model=PageResponseModel[WarehouseListItemModel],
dependencies=[UserInterfaceAuthDependency('master:warehouse:list')], dependencies=[UserInterfaceAuthDependency('master:warehouse:list')],
) )
async def get_master_warehouse_list( async def get_master_warehouse_list(
@@ -92,6 +94,9 @@ async def get_master_warehouse_list(
scope, scope,
is_page=True, 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) return ResponseUtil.success(model_content=warehouse_page_query_result)
@@ -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,
}
@@ -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,
}
@@ -2,12 +2,13 @@ from datetime import datetime
from typing import Any from typing import Any
from pydantic import BaseModel 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.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel from common.vo import PageModel
from module_admin.entity.do.user_do import SysUser from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import ( from module_business.entity.do.master_do import (
BizCustomer, BizCustomer,
@@ -39,6 +40,18 @@ from module_business.entity.vo.master_vo import (
) )
from utils.page_util import PageUtil 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: class MasterDao:
"""菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。""" """菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。"""
@@ -494,15 +507,44 @@ class MasterDao:
) -> PageModel | list[dict[str, Any]]: ) -> PageModel | list[dict[str, Any]]:
conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)] conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)]
if query_object.pallet_code: 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: if query_object.asset_status:
conditions.append(BizPallet.asset_status == 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)) ids = cls._parse_id_list(getattr(query_object, 'ids', None))
if ids: if ids:
conditions.append(BizPallet.pallet_id.in_(ids)) conditions.append(BizPallet.pallet_id.in_(ids))
statement = select(BizPallet).where(*conditions).order_by(BizPallet.pallet_id) statement = select(BizPallet).where(*conditions).order_by(BizPallet.pallet_id)
return await cls._paginate(db, statement, query_object, is_page) 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 @classmethod
async def get_pallet( async def get_pallet(
cls, cls,
@@ -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]
@@ -3,12 +3,13 @@ from typing import Any
from sqlalchemy import ColumnElement, Select, and_, delete, false, or_, select, update from sqlalchemy import ColumnElement, Select, and_, delete, false, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel from common.vo import PageModel
from module_business.domain.enums import DocumentStatus, PalletFlowState from module_business.domain.enums import DocumentStatus, PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition 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 ( from module_business.entity.do.operation_do import (
BizFillingRecord, BizFillingRecord,
BizInboundItem, BizInboundItem,
@@ -608,6 +609,15 @@ class OperationDao:
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%')) conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
if query_object.pallet_code: if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.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( conditions.extend(
cls._time_conditions( cls._time_conditions(
BizFillingRecord.operated_at, BizFillingRecord.operated_at,
@@ -616,7 +626,7 @@ class OperationDao:
) )
) )
statement = select(BizFillingRecord) statement = select(BizFillingRecord)
if query_object.pallet_code: if query_object.pallet_code or query_object.keyword:
statement = statement.join( statement = statement.join(
BizPallet, BizPallet,
BizPallet.pallet_id == BizFillingRecord.pallet_id, BizPallet.pallet_id == BizFillingRecord.pallet_id,
@@ -680,6 +690,10 @@ class OperationDao:
conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id) conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id)
if query_object.status: if query_object.status:
conditions.append(BizShipmentOrder.status == query_object.status.value) 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( conditions.extend(
cls._time_conditions( cls._time_conditions(
BizShipmentOrder.create_time, BizShipmentOrder.create_time,
@@ -687,7 +701,45 @@ class OperationDao:
query_object.end_time, 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) return await cls._paginate(db, statement, query_object, is_page)
@classmethod @classmethod
@@ -818,6 +870,11 @@ class OperationDao:
conditions.append(BizUsageRecord.line_id == query_object.line_id) conditions.append(BizUsageRecord.line_id == query_object.line_id)
if query_object.pallet_code: if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.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( conditions.extend(
cls._time_conditions( cls._time_conditions(
BizUsageRecord.occurred_at, BizUsageRecord.occurred_at,
@@ -826,7 +883,7 @@ class OperationDao:
) )
) )
statement = select(BizUsageRecord) statement = select(BizUsageRecord)
if query_object.pallet_code: if query_object.pallet_code or query_object.keyword:
statement = statement.join( statement = statement.join(
BizPallet, BizPallet,
BizPallet.pallet_id == BizUsageRecord.pallet_id, BizPallet.pallet_id == BizUsageRecord.pallet_id,
@@ -884,7 +941,63 @@ class OperationDao:
query_object.end_time, 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) return await cls._paginate(db, statement, query_object, is_page)
@classmethod @classmethod
@@ -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,
}
@@ -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
@@ -2,17 +2,76 @@
from typing import Any 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 sqlalchemy.ext.asyncio import AsyncSession
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition 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 BizPallet
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
# 待使用队列每个菌种最多返回的托盘数;总数仍完整统计
QUEUE_LIMIT_PER_STRAIN = 20
class UsageFifoDao: class UsageFifoDao:
@staticmethod @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( async def recommendation(
cls,
db: AsyncSession, db: AsyncSession,
*, *,
site_id: int, site_id: int,
@@ -23,27 +82,10 @@ class UsageFifoDao:
for_update: bool = False, for_update: bool = False,
) -> tuple[dict[str, Any] | None, int]: ) -> tuple[dict[str, Any] | None, int]:
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None)) conditions = cls._candidate_conditions(
conditions = [ site_id=site_id, strain_id=strain_id, tunnel_id=tunnel_id, trace_no=trace_no, scope=scope
inventory.site_id == site_id, )
inventory.warehouse_id.is_(None), base = cls._join_candidates(
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 = (
select( select(
pallet.pallet_id, pallet.pallet_id,
pallet.pallet_code, pallet.pallet_code,
@@ -51,22 +93,8 @@ class UsageFifoDao:
inventory.remaining_packages, inventory.remaining_packages,
inventory.site_arrived_at, inventory.site_arrived_at,
) )
.select_from(inventory) ).where(*conditions)
.join(pallet, pallet.pallet_id == inventory.pallet_id) statement = base.where(inventory.site_arrived_at.is_not(None)).order_by(*cls._fifo_order()).limit(1)
.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)
)
if for_update: if for_update:
# 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。 # 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。
# 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。 # 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。
@@ -83,3 +111,88 @@ class UsageFifoDao:
) )
) )
return (dict(first) if first is not None else None, unknown.scalar_one()) 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'],
),
)
@@ -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,
}
@@ -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='所属客户名称(现场选项)')
@@ -422,6 +422,9 @@ class PalletPageQueryModel(BaseModel):
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') 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='托盘编号') 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='资产状态') 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列表(英文逗号分隔)') ids: str | None = Field(default=None, description='主键ID列表(英文逗号分隔)')
@@ -250,6 +250,8 @@ class InboundItemModel(BaseModel):
inbound_item_id: int = Field(gt=0, description='入库明细ID') inbound_item_id: int = Field(gt=0, description='入库明细ID')
inbound_id: int = Field(gt=0, description='入库单ID') inbound_id: int = Field(gt=0, description='入库单ID')
pallet_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_by: str | None = Field(default=None, max_length=64, description='创建者')
create_time: datetime | None = Field(default=None, description='创建时间') create_time: datetime | None = Field(default=None, description='创建时间')
@@ -275,6 +277,44 @@ class InboundModel(BaseModel):
items: list[InboundItemModel] = Field(default_factory=list, description='入库托盘明细') 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): class FillingModel(BaseModel):
"""托盘装填记录展示模型。""" """托盘装填记录展示模型。"""
@@ -305,6 +345,68 @@ class FillingModel(BaseModel):
remark: str | None = Field(default=None, max_length=500, description='备注') 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): class ShipmentItemModel(BaseModel):
"""整托发货明细模型。""" """整托发货明细模型。"""
@@ -321,6 +423,10 @@ class ShipmentItemModel(BaseModel):
ge=0, ge=0,
description='当前库存剩余包数(只读,非确认快照)', 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_by: str | None = Field(default=None, max_length=64, description='创建者')
create_time: datetime | None = Field(default=None, description='创建时间') create_time: datetime | None = Field(default=None, description='创建时间')
@@ -350,6 +456,61 @@ class ShipmentModel(BaseModel):
items: list[ShipmentItemModel] = Field(default_factory=list, description='发货托盘明细') 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): class UsageModel(BaseModel):
"""客户现场使用记录展示模型。""" """客户现场使用记录展示模型。"""
@@ -373,6 +534,20 @@ class UsageModel(BaseModel):
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因') 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): class ReturnItemModel(BaseModel):
"""托盘退回明细模型。""" """托盘退回明细模型。"""
@@ -386,6 +561,8 @@ class ReturnItemModel(BaseModel):
trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照') trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照')
batch_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='申请时剩余包数快照') 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_by: str | None = Field(default=None, max_length=64, description='创建者')
create_time: datetime | None = Field(default=None, description='创建时间') create_time: datetime | None = Field(default=None, description='创建时间')
@@ -416,6 +593,37 @@ class ReturnModel(BaseModel):
items: list[ReturnItemModel] = Field(default_factory=list, description='退回托盘明细') 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): class InboundPageQueryModel(BaseModel):
"""首次入库分页查询模型。""" """首次入库分页查询模型。"""
@@ -440,6 +648,13 @@ class FillingPageQueryModel(BaseModel):
page_num: int = Field(default=1, gt=0, description='当前页码') page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, le=100, 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='装填单号') 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') warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID')
strain_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='生产批次') 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) _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): class ShipmentPageQueryModel(BaseModel):
"""整托发货分页查询模型。""" """整托发货分页查询模型。"""
@@ -458,9 +703,17 @@ class ShipmentPageQueryModel(BaseModel):
page_num: int = Field(default=1, gt=0, description='当前页码') page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, le=100, 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='发货单号') 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') source_warehouse_id: int | None = Field(default=None, gt=0, description='来源仓库ID')
target_site_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='单据状态') 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='开始时间') 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='结束时间') 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_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, le=100, 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='使用记录号') 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') site_id: int | None = Field(default=None, gt=0, description='现场ID')
tunnel_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='物料溯源号') 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_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, le=100, 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='退回单号') 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') source_site_id: int | None = Field(default=None, gt=0, description='来源现场ID')
target_warehouse_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='退回状态') 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='业务名称') 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): class PalletScanResponseModel(BaseModel):
"""业务扫码托盘响应模型。""" """业务扫码托盘响应模型。"""
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import date, datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel from pydantic.alias_generators import to_camel
@@ -21,3 +21,34 @@ class UsageFifoRecommendationModel(BaseModel):
unknown_arrival_count: int = 0 unknown_arrival_count: int = 0
requires_skip_reason: bool = False requires_skip_reason: bool = False
message: str 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] = []
@@ -1,7 +1,7 @@
import hashlib import hashlib
import json import json
import secrets import secrets
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, NoReturn from typing import Any, NoReturn
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel
from exceptions.exception import PermissionException, ServiceException from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao from module_business.dao.access_dao import AccessDao
from module_business.dao.filling_summary_dao import FillingSummaryDao
from module_business.dao.inventory_dao import InventoryDao from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao 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.master_vo import StrainPageQueryModel
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
FillingBatchDetailModel,
FillingBatchDetailQueryModel,
FillingBatchModel,
FillingBatchPageQueryModel,
FillingCreateModel, FillingCreateModel,
FillingListItemModel,
FillingModel, FillingModel,
FillingOverviewModel,
FillingPageQueryModel, FillingPageQueryModel,
PalletScanResponseModel, PalletScanResponseModel,
) )
@@ -34,6 +41,9 @@ from module_business.service.test_batch_access_service import TestBatchAccessSer
class FillingService: class FillingService:
"""仓库内单托盘即时装填服务层。""" """仓库内单托盘即时装填服务层。"""
# 有效期剩余不超过该天数的在仓批次视为临期
NEAR_EXPIRY_DAYS = 14
@staticmethod @staticmethod
def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]: def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]:
user = current_user.user user = current_user.user
@@ -187,7 +197,7 @@ class FillingService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[FillingModel]: ) -> PageModel | list[FillingListItemModel]:
warehouse_ids = await cls._warehouse_scope(query_db, current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if ( if (
warehouse_ids is not None warehouse_ids is not None
@@ -203,11 +213,81 @@ class FillingService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result 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): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return 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 @classmethod
async def filling_detail_services( async def filling_detail_services(
cls, cls,
@@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel
from exceptions.exception import PermissionException, ServiceException, ServiceWarning from exceptions.exception import PermissionException, ServiceException, ServiceWarning
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao from module_business.dao.access_dao import AccessDao
from module_business.dao.inbound_summary_dao import InboundSummaryDao
from module_business.dao.inventory_dao import InventoryDao from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao from module_business.dao.operation_dao import OperationDao
@@ -29,7 +30,10 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
ConfirmCommandModel, ConfirmCommandModel,
InboundCreateModel, InboundCreateModel,
InboundItemModel,
InboundListItemModel,
InboundModel, InboundModel,
InboundOverviewModel,
InboundPageQueryModel, InboundPageQueryModel,
InboundScanQueryModel, InboundScanQueryModel,
InboundUpdateModel, InboundUpdateModel,
@@ -245,7 +249,7 @@ class InboundService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[InboundModel]: ) -> PageModel | list[InboundListItemModel]:
warehouse_ids = await cls._warehouse_scope(query_db, current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if ( if (
warehouse_ids is not None warehouse_ids is not None
@@ -262,7 +266,17 @@ class InboundService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result 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): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return models return models
@@ -284,7 +298,31 @@ class InboundService:
) )
if inbound is None: if inbound is None:
cls._raise_inbound_not_accessible(current_user) 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 @classmethod
async def get_warehouse_options_services( async def get_warehouse_options_services(
@@ -15,6 +15,7 @@ from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.domain.test_batch_scope import TestBatchScope 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 ( from module_business.entity.vo.master_vo import (
CustomerModel, CustomerModel,
LineModel, LineModel,
@@ -33,6 +34,11 @@ _MAX_IMPORT_BYTES = 5 * 1024 * 1024
_MAX_IMPORT_ROWS = 5000 _MAX_IMPORT_ROWS = 5000
_STATUS_TO_VALUE = {'正常': '0', '停用': '1'} _STATUS_TO_VALUE = {'正常': '0', '停用': '1'}
_VALUE_TO_STATUS = {'0': '正常', '1': '停用'} _VALUE_TO_STATUS = {'0': '正常', '1': '停用'}
_PREVIEW_MESSAGES: dict[ImportOutcome, str] = {
'add': '校验通过,将新增',
'update': '{code}已存在,将按文件内容更新',
'skip': '{code}已存在,保持原资料不变',
}
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -245,6 +251,65 @@ class MasterExcelService:
raise ServiceException(message='导入失败,数据已全部回滚') from exc raise ServiceException(message='导入失败,数据已全部回滚') from exc
return CrudResponseModel(is_success=True, message=cls._summary(added, updated, skipped, details)) 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 @classmethod
async def _read_upload(cls, resource: MasterExcelResource, file: UploadFile) -> list[ParsedRow]: async def _read_upload(cls, resource: MasterExcelResource, file: UploadFile) -> list[ParsedRow]:
spec = _SPECS[resource] spec = _SPECS[resource]
@@ -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]
@@ -16,6 +16,7 @@ from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao 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.enums import PalletEventType, PalletFlowState, ReturnStatus
from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.pallet_state_machine import PalletStateMachine
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope 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, BusinessOptionModel,
PalletScanResponseModel, PalletScanResponseModel,
ReturnConfirmModel, ReturnConfirmModel,
ReturnItemModel,
ReturnListItemModel,
ReturnModel, ReturnModel,
ReturnOverviewModel,
ReturnPageQueryModel, ReturnPageQueryModel,
ReturnRequestModel, ReturnRequestModel,
ReturnScanQueryModel, ReturnScanQueryModel,
ReturnSiteOptionModel,
) )
from module_business.service.access_service import AccessService from module_business.service.access_service import AccessService
from module_business.service.test_batch_access_service import TestBatchAccessService from module_business.service.test_batch_access_service import TestBatchAccessService
@@ -211,7 +216,7 @@ class ReturnService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[ReturnModel]: ) -> PageModel | list[ReturnListItemModel]:
site_ids, warehouse_ids = await cls._scopes(query_db, current_user) site_ids, warehouse_ids = await cls._scopes(query_db, current_user)
if site_ids == [] and warehouse_ids == []: if site_ids == [] and warehouse_ids == []:
if is_page: if is_page:
@@ -233,7 +238,17 @@ class ReturnService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result 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): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return models return models
@@ -257,7 +272,42 @@ class ReturnService:
if return_order is None: if return_order is None:
cls._raise_return_not_accessible(current_user) cls._raise_return_not_accessible(current_user)
assert_record_in_scope(return_order, test_scope, '退回单') 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 @classmethod
async def get_warehouse_options_services( async def get_warehouse_options_services(
@@ -14,6 +14,8 @@ from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao 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.enums import DocumentStatus, PalletEventType, PalletFlowState
from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.pallet_state_machine import PalletStateMachine
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope 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, ConfirmCommandModel,
PalletScanResponseModel, PalletScanResponseModel,
ShipmentCreateModel, ShipmentCreateModel,
ShipmentListItemModel,
ShipmentModel, ShipmentModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
ShipmentScanQueryModel, ShipmentScanQueryModel,
@@ -254,7 +257,7 @@ class ShipmentService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[ShipmentModel]: ) -> PageModel | list[ShipmentListItemModel]:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user) warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
if ( if (
warehouse_ids is not None warehouse_ids is not None
@@ -288,7 +291,17 @@ class ShipmentService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result 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): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return models return models
@@ -311,7 +324,35 @@ class ShipmentService:
) )
if shipment is None: if shipment is None:
cls._raise_shipment_not_accessible(current_user) 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 @classmethod
async def get_warehouse_options_services( async def get_warehouse_options_services(
@@ -6,7 +6,8 @@ from module_business.dao.master_dao import MasterDao
from module_business.dao.usage_fifo_dao import UsageFifoDao from module_business.dao.usage_fifo_dao import UsageFifoDao
from module_business.domain.fifo_policy import requires_fifo_skip_reason 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.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 from module_business.service.usage_service import UsageService
@@ -52,3 +53,19 @@ class UsageFifoService:
recommended=candidate, unknown_arrival_count=unknown, recommended=candidate, unknown_arrival_count=unknown,
requires_skip_reason=needs_reason, message=message, 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})
@@ -3,7 +3,7 @@
import hashlib import hashlib
import json import json
import secrets import secrets
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, NoReturn from typing import Any, NoReturn
from sqlalchemy.exc import IntegrityError 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.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao from module_business.dao.operation_dao import OperationDao
from module_business.dao.usage_fifo_dao import UsageFifoDao 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.enums import PalletEventType, PalletFlowState
from module_business.domain.fifo_policy import requires_fifo_skip_reason from module_business.domain.fifo_policy import requires_fifo_skip_reason
from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.pallet_state_machine import PalletStateMachine
@@ -29,8 +30,11 @@ from module_business.entity.vo.operation_vo import (
TunnelEntryCreateModel, TunnelEntryCreateModel,
TunnelEntrySuccessResponseModel, TunnelEntrySuccessResponseModel,
UsageCreateModel, UsageCreateModel,
UsageListItemModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSiteBoardModel,
UsageSiteOptionModel,
UsageSuccessResponseModel, UsageSuccessResponseModel,
) )
from module_business.service.access_service import AccessService from module_business.service.access_service import AccessService
@@ -279,7 +283,7 @@ class UsageService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[UsageModel]: ) -> PageModel | list[UsageListItemModel]:
site_ids = await cls._site_scope(query_db, current_user) 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: 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='无权访问该客户现场') raise PermissionException(data='', message='无权访问该客户现场')
@@ -301,11 +305,56 @@ class UsageService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result 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): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return 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 @classmethod
async def usage_detail_services( async def usage_detail_services(
cls, cls,
@@ -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', 'GET'): 'master:warehouse:assign',
('/master/warehouse/{warehouse_id}/users', 'PUT'): 'master:warehouse:assign', ('/master/warehouse/{warehouse_id}/users', 'PUT'): 'master:warehouse:assign',
('/master/pallet/{pallet_id}/qrcode', 'GET'): 'master:pallet:print', ('/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/list', 'GET'): 'business:inbound:list',
('/business/inbound/summary', 'GET'): 'business:inbound:list',
('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query', ('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query',
('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query', ('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query',
('/business/inbound/{inbound_id}', '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_ids}', 'DELETE'): 'business:inbound:list',
('/business/inbound/{inbound_id}/confirm', 'PUT'): 'business:inbound:confirm', ('/business/inbound/{inbound_id}/confirm', 'PUT'): 'business:inbound:confirm',
('/business/filling/list', 'GET'): 'business:filling:list', ('/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/strain-options', 'GET'): 'business:filling:query',
('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query', ('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query',
('/business/filling/{filling_id}', '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}/scan/{identifier}', 'GET'): 'business:shipment:receive',
('/business/shipment-receipt/{shipment_id}/confirm', 'PUT'): 'business:shipment:receive', ('/business/shipment-receipt/{shipment_id}/confirm', 'PUT'): 'business:shipment:receive',
('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query', ('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query',
('/business/usage-fifo/queue', 'GET'): 'business:usage:query',
('/business/shipment', 'POST'): 'business:shipment:add', ('/business/shipment', 'POST'): 'business:shipment:add',
('/business/shipment', 'PUT'): 'business:shipment:edit', ('/business/shipment', 'PUT'): 'business:shipment:edit',
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list', ('/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/list', 'GET'): 'business:usage:list',
('/business/usage/line-options', 'GET'): 'business:usage:query', ('/business/usage/line-options', 'GET'): 'business:usage:query',
('/business/usage/tunnel-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/enter-tunnel', 'POST'): 'business:usage:add',
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query', ('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
('/business/usage/{usage_id}', 'GET'): 'business:usage:query', ('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
('/business/usage', 'POST'): 'business:usage:add', ('/business/usage', 'POST'): 'business:usage:add',
('/business/return/list', 'GET'): 'business:return:list', ('/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/warehouse-options', 'GET'): 'business:return:query',
('/business/return/scan/{qr_token}', 'GET'): 'business:return:query', ('/business/return/scan/{qr_token}', 'GET'): 'business:return:query',
('/business/return/{return_id}', '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() EXPECTED_ROUTE_PERMISSIONS = _build_expected_route_permissions()
STATIC_READS_BEFORE_DETAIL = { 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/{inbound_id}': (
'/business/inbound/summary',
'/business/inbound/warehouse-options', '/business/inbound/warehouse-options',
'/business/inbound/scan/{qr_token}', '/business/inbound/scan/{qr_token}',
), ),
'/business/filling/{filling_id}': ( '/business/filling/{filling_id}': (
'/business/filling/summary',
'/business/filling/batches',
'/business/filling/batches/detail',
'/business/filling/strain-options', '/business/filling/strain-options',
'/business/filling/scan/{qr_token}', '/business/filling/scan/{qr_token}',
), ),
@@ -311,9 +330,13 @@ STATIC_READS_BEFORE_DETAIL = {
'/business/usage/{usage_id}': ( '/business/usage/{usage_id}': (
'/business/usage/line-options', '/business/usage/line-options',
'/business/usage/tunnel-options', '/business/usage/tunnel-options',
'/business/usage/site-options',
'/business/usage/site-board',
'/business/usage/scan/{qr_token}', '/business/usage/scan/{qr_token}',
), ),
'/business/return/{return_id}': ( '/business/return/{return_id}': (
'/business/return/summary',
'/business/return/site-options',
'/business/return/warehouse-options', '/business/return/warehouse-options',
'/business/return/scan/{qr_token}', '/business/return/scan/{qr_token}',
), ),
@@ -327,6 +350,8 @@ EXACT_QUERY_PARAMETERS = {
('/business/shipment/scan/{qr_token}', 'GET'): {'sourceWarehouseId', 'targetSiteId'}, ('/business/shipment/scan/{qr_token}', 'GET'): {'sourceWarehouseId', 'targetSiteId'},
('/business/usage/line-options', 'GET'): {'siteId'}, ('/business/usage/line-options', 'GET'): {'siteId'},
('/business/usage/tunnel-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'}, ('/business/return/scan/{qr_token}', 'GET'): {'mode', 'returnId'},
} }
REQUIRED_QUERY_PARAMETERS = { 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: def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None:
expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values()) 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, Counter(literals))
test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected)) 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(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/')) openapi_ai_paths = sorted(path for path in self.schema['paths'] if path == '/ai' or path.startswith('/ai/'))
app_ai_paths = sorted( app_ai_paths = sorted(
@@ -582,7 +607,7 @@ class ApiPermissionContractTest(unittest.TestCase):
def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None: def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None:
literals = list(EXPECTED_ROUTE_PERMISSIONS.values()) literals = list(EXPECTED_ROUTE_PERMISSIONS.values())
self.assertEqual(119, len(literals)) self.assertEqual(131, len(literals))
literals.append(literals[0]) literals.append(literals[0])
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
@@ -21,6 +21,7 @@ from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
from module_admin.service.login_service import LoginService from module_admin.service.login_service import LoginService
from module_business.controller import filling_controller as filling_controller_module from module_business.controller import filling_controller as filling_controller_module
from module_business.dao.access_dao import AccessDao 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.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao 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.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
FillingBatchDetailModel,
FillingBatchModel,
FillingCreateModel, FillingCreateModel,
FillingListItemModel,
FillingModel, FillingModel,
FillingOverviewModel,
FillingPageQueryModel, FillingPageQueryModel,
PalletScanResponseModel, PalletScanResponseModel,
) )
@@ -242,6 +247,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
with ( with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[9, 7, 7])), 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(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()) 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() schema = app.openapi()
expected = { expected = {
('/business/filling/list', 'get'), ('/business/filling/list', 'get'),
('/business/filling/summary', 'get'),
('/business/filling/batches', 'get'),
('/business/filling/batches/detail', 'get'),
('/business/filling/strain-options', 'get'), ('/business/filling/strain-options', 'get'),
('/business/filling/scan/{qr_token}', 'get'), ('/business/filling/scan/{qr_token}', 'get'),
('/business/filling/{filling_id}', '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}) self.assertEqual({'qr_token'}, {parameter['name'] for parameter in scan_parameters})
routes = self._routes() 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( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], DataResponseModel[list[BusinessOptionModel]],
routes[('/business/filling/strain-options', 'GET')].response_model, 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: def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
paths = [route.path_format for route in filling_controller_module.filling_controller.routes] paths = [route.path_format for route in filling_controller_module.filling_controller.routes]
detail_index = paths.index('/business/filling/{filling_id}') detail_index = paths.index('/business/filling/{filling_id}')
self.assertLess(paths.index('/business/filling/strain-options'), detail_index) for static_path in (
self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index) '/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: def test_each_route_has_exact_permission_and_three_unique_values(self) -> None:
expected = { expected = {
('/business/filling/list', 'GET'): 'business:filling:list', ('/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/strain-options', 'GET'): 'business:filling:query',
('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query', ('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query',
('/business/filling/{filling_id}', 'GET'): 'business:filling:query', ('/business/filling/{filling_id}', 'GET'): 'business:filling:query',
@@ -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()
@@ -28,6 +28,7 @@ from module_admin.service.log_service import LogQueueService
from module_admin.service.login_service import LoginService from module_admin.service.login_service import LoginService
from module_business.controller import inbound_controller as inbound_controller_module from module_business.controller import inbound_controller as inbound_controller_module
from module_business.dao.access_dao import AccessDao 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.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao from module_business.dao.operation_dao import OperationDao
@@ -45,7 +46,9 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
ConfirmCommandModel, ConfirmCommandModel,
InboundCreateModel, InboundCreateModel,
InboundListItemModel,
InboundModel, InboundModel,
InboundOverviewModel,
InboundPageQueryModel, InboundPageQueryModel,
InboundScanQueryModel, InboundScanQueryModel,
InboundUpdateModel, InboundUpdateModel,
@@ -741,6 +744,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
with ( with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 3, 7])) as scope, 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(OperationDao, 'get_inbound_list', new=get_list),
patch.object(InboundSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
): ):
actual = await InboundService.get_inbound_list_services( actual = await InboundService.get_inbound_list_services(
self.db, query, self.user, TestBatchScope.formal(), is_page=True self.db, query, self.user, TestBatchScope.formal(), is_page=True
@@ -827,6 +831,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
) as scope, ) as scope,
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
patch.object(AccessService, 'assert_warehouse_access', new=access), 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()) actual = await InboundService.inbound_detail_services(self.db, 11, self.user, TestBatchScope.formal())
@@ -1900,6 +1905,7 @@ class TestInboundController(unittest.TestCase):
schema = app.openapi() schema = app.openapi()
expected = { expected = {
('/business/inbound/list', 'get'), ('/business/inbound/list', 'get'),
('/business/inbound/summary', 'get'),
('/business/inbound/warehouse-options', 'get'), ('/business/inbound/warehouse-options', 'get'),
('/business/inbound/scan/{qr_token}', 'get'), ('/business/inbound/scan/{qr_token}', 'get'),
('/business/inbound/{inbound_id}', 'get'), ('/business/inbound/{inbound_id}', 'get'),
@@ -1913,7 +1919,12 @@ class TestInboundController(unittest.TestCase):
self.assertIn('warehouseId', {parameter['name'] for parameter in scan_parameters}) self.assertIn('warehouseId', {parameter['name'] for parameter in scan_parameters})
routes = self._routes() 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( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], DataResponseModel[list[BusinessOptionModel]],
routes[('/business/inbound/warehouse-options', 'GET')].response_model, 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: def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
paths = [route.path_format for route in inbound_controller_module.inbound_controller.routes] paths = [route.path_format for route in inbound_controller_module.inbound_controller.routes]
detail_index = paths.index('/business/inbound/{inbound_id}') 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/warehouse-options'), detail_index)
self.assertLess(paths.index('/business/inbound/scan/{qr_token}'), 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: def test_each_route_has_exact_permission_and_five_unique_permission_values(self) -> None:
expected = { expected = {
('/business/inbound/list', 'GET'): 'business:inbound: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/warehouse-options', 'GET'): 'business:inbound:query',
('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query', ('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query',
('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query', ('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query',
@@ -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()
@@ -33,6 +33,14 @@ from module_business.entity.do.master_do import (
BizStrain, BizStrain,
BizWarehouse, BizWarehouse,
) )
from module_business.entity.vo.master_summary_vo import (
CustomerDetailModel,
CustomerListItemModel,
LineListItemModel,
SiteListItemModel,
StrainListItemModel,
WarehouseListItemModel,
)
from module_business.entity.vo.master_vo import ( from module_business.entity.vo.master_vo import (
CustomerModel, CustomerModel,
CustomerPageQueryModel, CustomerPageQueryModel,
@@ -1060,17 +1068,24 @@ class TestMasterControllers(unittest.TestCase):
def test_route_response_model_contracts(self) -> None: def test_route_response_model_contracts(self) -> None:
model_by_resource = { model_by_resource = {
'strain': StrainModel, 'strain': StrainModel,
'customer': CustomerModel, 'customer': CustomerDetailModel,
'site': SiteModel, 'site': SiteModel,
'line': LineModel, 'line': LineModel,
'warehouse': WarehouseModel, 'warehouse': WarehouseModel,
} }
list_model_by_resource = {
'strain': StrainListItemModel,
'customer': CustomerListItemModel,
'site': SiteListItemModel,
'line': LineListItemModel,
'warehouse': WarehouseListItemModel,
}
for resource, router in self.ROUTERS.items(): for resource, router in self.ROUTERS.items():
for route in router.routes: for route in router.routes:
if not isinstance(route, APIRoute): if not isinstance(route, APIRoute):
continue continue
if route.path.endswith('/list'): 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'}: elif route.methods == {'GET'}:
self.assertEqual(DataResponseModel[model_by_resource[resource]], route.response_model) self.assertEqual(DataResponseModel[model_by_resource[resource]], route.response_model)
else: else:
@@ -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()
@@ -34,6 +34,7 @@ from module_business.entity.do.master_do import (
BizWarehouse, BizWarehouse,
) )
from module_business.entity.do.operation_do import BizPalletInventory 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 ( from module_business.entity.vo.master_vo import (
BusinessUserOptionModel, BusinessUserOptionModel,
DeletePalletModel, DeletePalletModel,
@@ -1285,11 +1286,11 @@ class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase):
def test_route_response_models_are_exact(self) -> None: def test_route_response_models_are_exact(self) -> None:
routes = self._routes() routes = self._routes()
self.assertEqual( self.assertEqual(
PageResponseModel[PalletModel], PageResponseModel[PalletListItemModel],
routes[('/master/pallet/list', 'GET')].response_model, routes[('/master/pallet/list', 'GET')].response_model,
) )
self.assertEqual( self.assertEqual(
DataResponseModel[PalletModel], DataResponseModel[PalletDetailModel],
routes[('/master/pallet/{pallet_id}', 'GET')].response_model, routes[('/master/pallet/{pallet_id}', 'GET')].response_model,
) )
for path, method in ( for path, method in (
@@ -38,10 +38,13 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
PalletScanResponseModel, PalletScanResponseModel,
ReturnConfirmModel, ReturnConfirmModel,
ReturnListItemModel,
ReturnModel, ReturnModel,
ReturnOverviewModel,
ReturnPageQueryModel, ReturnPageQueryModel,
ReturnRequestModel, ReturnRequestModel,
ReturnScanQueryModel, ReturnScanQueryModel,
ReturnSiteOptionModel,
) )
from module_business.service.access_service import AccessService from module_business.service.access_service import AccessService
from module_business.service.test_batch_access_service import TestBatchAccessService from module_business.service.test_batch_access_service import TestBatchAccessService
@@ -440,6 +443,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
schema = app.openapi() schema = app.openapi()
expected = { expected = {
('/business/return/list', 'get'), ('/business/return/list', 'get'),
('/business/return/summary', 'get'),
('/business/return/site-options', 'get'),
('/business/return/warehouse-options', 'get'), ('/business/return/warehouse-options', 'get'),
('/business/return/scan/{qr_token}', 'get'), ('/business/return/scan/{qr_token}', 'get'),
('/business/return/{return_id}', 'get'), ('/business/return/{return_id}', 'get'),
@@ -450,7 +455,17 @@ class ReturnControllerContractRedTest(unittest.TestCase):
parameters = schema['paths']['/business/return/scan/{qr_token}']['get']['parameters'] parameters = schema['paths']['/business/return/scan/{qr_token}']['get']['parameters']
self.assertEqual({'qr_token', 'mode', 'returnId'}, {parameter['name'] for parameter in parameters}) self.assertEqual({'qr_token', 'mode', 'returnId'}, {parameter['name'] for parameter in parameters})
routes = self._routes() 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( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], DataResponseModel[list[BusinessOptionModel]],
routes[('/business/return/warehouse-options', 'GET')].response_model, routes[('/business/return/warehouse-options', 'GET')].response_model,
@@ -472,6 +487,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
detail_index = paths.index('/business/return/{return_id}') detail_index = paths.index('/business/return/{return_id}')
for static_path in ( for static_path in (
'/business/return/list', '/business/return/list',
'/business/return/summary',
'/business/return/site-options',
'/business/return/warehouse-options', '/business/return/warehouse-options',
'/business/return/scan/{qr_token}', '/business/return/scan/{qr_token}',
'/business/return/request', '/business/return/request',
@@ -480,6 +497,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
expected = { expected = {
('/business/return/list', 'GET'): 'business:return:list', ('/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/warehouse-options', 'GET'): 'business:return:query',
('/business/return/scan/{qr_token}', 'GET'): 'business:return:query', ('/business/return/scan/{qr_token}', 'GET'): 'business:return:query',
('/business/return/{return_id}', 'GET'): 'business:return:query', ('/business/return/{return_id}', 'GET'): 'business:return:query',
@@ -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()
@@ -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()
@@ -31,6 +31,8 @@ from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao 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.enums import PalletEventType, PalletFlowState
from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.pallet_state_machine import PalletStateMachine
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
@@ -47,6 +49,7 @@ from module_business.entity.vo.operation_vo import (
ConfirmCommandModel, ConfirmCommandModel,
PalletScanResponseModel, PalletScanResponseModel,
ShipmentCreateModel, ShipmentCreateModel,
ShipmentListItemModel,
ShipmentModel, ShipmentModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
ShipmentScanQueryModel, ShipmentScanQueryModel,
@@ -425,7 +428,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
) )
routes = self._routes() 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'): for path in ('warehouse-options', 'site-options'):
self.assertEqual( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], 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(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_list', new=get_list),
patch.object(OperationDao, 'get_shipment_by_id', new=get_detail), 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( listed = await ShipmentService.get_shipment_list_services(
self.db, self.db,
@@ -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.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizPallet from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory 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_fifo_service import UsageFifoService
from module_business.service.usage_service import UsageService from module_business.service.usage_service import UsageService
from module_business.dao.master_dao import MasterDao 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.db, 'qr-2', MagicMock(), TestBatchScope.formal(), tunnel_id=13)
self.assertEqual(result.recommended.pallet_id, 1) self.assertEqual(result.recommended.pallet_id, 1)
self.assertTrue(result.requires_skip_reason) 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'])
@@ -30,6 +30,7 @@ from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao 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.enums import PalletEventType, PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine, BizTunnel 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, PalletScanResponseModel,
TunnelEntrySuccessResponseModel, TunnelEntrySuccessResponseModel,
UsageCreateModel, UsageCreateModel,
UsageListItemModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSiteBoardModel,
UsageSiteOptionModel,
UsageSuccessResponseModel, UsageSuccessResponseModel,
) )
from module_business.service.access_service import AccessService from module_business.service.access_service import AccessService
@@ -381,6 +385,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase):
with ( with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[11, 9, 9])), 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(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()) 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/list', 'get'),
('/business/usage/line-options', 'get'), ('/business/usage/line-options', 'get'),
('/business/usage/tunnel-options', 'get'), ('/business/usage/tunnel-options', 'get'),
('/business/usage/site-options', 'get'),
('/business/usage/site-board', 'get'),
('/business/usage/enter-tunnel', 'post'), ('/business/usage/enter-tunnel', 'post'),
('/business/usage/scan/{qr_token}', 'get'), ('/business/usage/scan/{qr_token}', 'get'),
('/business/usage/{usage_id}', 'get'), ('/business/usage/{usage_id}', 'get'),
@@ -674,7 +681,15 @@ class TestUsageControllerContract(unittest.TestCase):
) )
routes = self._routes() 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( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], DataResponseModel[list[BusinessOptionModel]],
routes[('/business/usage/line-options', 'GET')].response_model, routes[('/business/usage/line-options', 'GET')].response_model,
@@ -717,6 +732,8 @@ class TestUsageControllerContract(unittest.TestCase):
detail_index = paths.index('/business/usage/{usage_id}') detail_index = paths.index('/business/usage/{usage_id}')
self.assertLess(paths.index('/business/usage/line-options'), detail_index) 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/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/enter-tunnel'), detail_index)
self.assertLess(paths.index('/business/usage/scan/{qr_token}'), 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/list', 'GET'): 'business:usage:list',
('/business/usage/line-options', 'GET'): 'business:usage:query', ('/business/usage/line-options', 'GET'): 'business:usage:query',
('/business/usage/tunnel-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/enter-tunnel', 'POST'): 'business:usage:add',
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query', ('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
('/business/usage/{usage_id}', 'GET'): 'business:usage:query', ('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
@@ -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()
@@ -30,31 +30,52 @@ from module_business.entity.vo.master_vo import (
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
ConfirmCommandModel, ConfirmCommandModel,
FillingBatchDetailModel,
FillingBatchDetailQueryModel,
FillingBatchModel,
FillingBatchPageQueryModel,
FillingBatchRecordModel,
FillingBatchStateCountModel,
FillingCreateModel, FillingCreateModel,
FillingListItemModel,
FillingModel, FillingModel,
FillingOverviewModel,
FillingPageQueryModel, FillingPageQueryModel,
InboundCreateModel, InboundCreateModel,
InboundItemModel, InboundItemModel,
InboundListItemModel,
InboundModel, InboundModel,
InboundOverviewModel,
InboundPageQueryModel, InboundPageQueryModel,
InboundScanQueryModel, InboundScanQueryModel,
InboundUpdateModel, InboundUpdateModel,
InboundWarehouseCountModel,
PalletScanResponseModel, PalletScanResponseModel,
ReturnConfirmModel, ReturnConfirmModel,
ReturnItemModel, ReturnItemModel,
ReturnListItemModel,
ReturnModel, ReturnModel,
ReturnOverviewModel,
ReturnPageQueryModel, ReturnPageQueryModel,
ReturnRequestModel, ReturnRequestModel,
ReturnScanQueryModel, ReturnScanQueryModel,
ReturnSiteOptionModel,
ShipmentCreateModel, ShipmentCreateModel,
ShipmentItemModel, ShipmentItemModel,
ShipmentListItemModel,
ShipmentModel, ShipmentModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
ShipmentScanQueryModel, ShipmentScanQueryModel,
ShipmentStrainSummaryModel,
ShipmentUpdateModel, ShipmentUpdateModel,
UsageBoardTunnelModel,
UsageCreateModel, UsageCreateModel,
UsageLinePackagesModel,
UsageListItemModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSiteBoardModel,
UsageSiteOptionModel,
) )
from module_business.entity.vo.report_vo import ( from module_business.entity.vo.report_vo import (
ConsumptionReportModel, ConsumptionReportModel,
@@ -170,7 +191,7 @@ MASTER_MODEL_FIELDS = {
}, },
PalletCreateModel: {'pallet_code', 'capacity_packages', 'remark'}, PalletCreateModel: {'pallet_code', 'capacity_packages', 'remark'},
PalletEditModel: {'pallet_id', 'capacity_packages', 'asset_status', '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'}, DeletePalletModel: {'pallet_ids'},
UserAssignModel: {'user_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'}, 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'}, ReturnRequestModel: {'source_site_id', 'target_warehouse_id', 'pallet_ids', 'idempotency_key'},
ReturnConfirmModel: {'scanned_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: { InboundModel: {
'inbound_id', 'inbound_id',
'inbound_no', 'inbound_no',
@@ -212,6 +241,38 @@ OPERATION_MODEL_FIELDS = {
'remark', 'remark',
'items', '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: { FillingModel: {
'filling_id', 'filling_id',
'filling_no', 'filling_no',
@@ -237,6 +298,88 @@ OPERATION_MODEL_FIELDS = {
'update_time', 'update_time',
'remark', '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: { ShipmentItemModel: {
'shipment_item_id', 'shipment_item_id',
'shipment_id', 'shipment_id',
@@ -244,6 +387,10 @@ OPERATION_MODEL_FIELDS = {
'active_filling_id', 'active_filling_id',
'package_qty', 'package_qty',
'remaining_packages', 'remaining_packages',
'pallet_code',
'strain_code',
'strain_name',
'batch_no',
'create_by', 'create_by',
'create_time', 'create_time',
}, },
@@ -267,6 +414,37 @@ OPERATION_MODEL_FIELDS = {
'remark', 'remark',
'items', '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: { UsageModel: {
'usage_id', 'usage_id',
'fifo_skip_reason', 'fifo_skip_reason',
@@ -285,6 +463,63 @@ OPERATION_MODEL_FIELDS = {
'operator_id', 'operator_id',
'occurred_at', '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: { ReturnItemModel: {
'return_item_id', 'return_item_id',
'return_id', 'return_id',
@@ -294,6 +529,8 @@ OPERATION_MODEL_FIELDS = {
'trace_no_snapshot', 'trace_no_snapshot',
'batch_no_snapshot', 'batch_no_snapshot',
'remaining_packages_snapshot', 'remaining_packages_snapshot',
'pallet_code',
'tunnel_name',
'create_by', 'create_by',
'create_time', 'create_time',
}, },
@@ -318,6 +555,48 @@ OPERATION_MODEL_FIELDS = {
'remark', 'remark',
'items', '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: { InboundPageQueryModel: {
'page_num', 'page_num',
'page_size', 'page_size',
@@ -331,6 +610,7 @@ OPERATION_MODEL_FIELDS = {
'page_num', 'page_num',
'page_size', 'page_size',
'filling_no', 'filling_no',
'keyword',
'warehouse_id', 'warehouse_id',
'strain_id', 'strain_id',
'batch_no', 'batch_no',
@@ -338,13 +618,24 @@ OPERATION_MODEL_FIELDS = {
'begin_time', 'begin_time',
'end_time', 'end_time',
}, },
FillingBatchPageQueryModel: {
'page_num',
'page_size',
'keyword',
'strain_id',
'begin_time',
'end_time',
},
FillingBatchDetailQueryModel: {'strain_id', 'batch_no'},
ShipmentPageQueryModel: { ShipmentPageQueryModel: {
'page_num', 'page_num',
'page_size', 'page_size',
'shipment_no', 'shipment_no',
'keyword',
'source_warehouse_id', 'source_warehouse_id',
'target_site_id', 'target_site_id',
'status', 'status',
'receipt_state',
'begin_time', 'begin_time',
'end_time', 'end_time',
}, },
@@ -352,6 +643,8 @@ OPERATION_MODEL_FIELDS = {
'page_num', 'page_num',
'page_size', 'page_size',
'usage_no', 'usage_no',
'keyword',
'fifo_skipped',
'site_id', 'site_id',
'tunnel_id', 'tunnel_id',
'trace_no', 'trace_no',
@@ -364,6 +657,7 @@ OPERATION_MODEL_FIELDS = {
'page_num', 'page_num',
'page_size', 'page_size',
'return_no', 'return_no',
'keyword',
'source_site_id', 'source_site_id',
'target_warehouse_id', 'target_warehouse_id',
'status', 'status',
@@ -374,6 +668,8 @@ OPERATION_MODEL_FIELDS = {
ShipmentScanQueryModel: {'source_warehouse_id', 'target_site_id'}, ShipmentScanQueryModel: {'source_warehouse_id', 'target_site_id'},
ReturnScanQueryModel: {'mode', 'return_id'}, ReturnScanQueryModel: {'mode', 'return_id'},
BusinessOptionModel: {'id', 'code', 'name'}, BusinessOptionModel: {'id', 'code', 'name'},
ReturnSiteOptionModel: {'id', 'code', 'name', 'customer_name'},
UsageSiteOptionModel: {'id', 'code', 'name', 'customer_name', 'tunnel_count', 'line_count'},
PalletScanResponseModel: { PalletScanResponseModel: {
'pallet_id', 'pallet_id',
'pallet_code', 'pallet_code',
@@ -726,6 +1022,7 @@ WRITE_MODELS = (
OPERATION_PAGE_QUERY_MODELS = ( OPERATION_PAGE_QUERY_MODELS = (
InboundPageQueryModel, InboundPageQueryModel,
FillingPageQueryModel, FillingPageQueryModel,
FillingBatchPageQueryModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
UsagePageQueryModel, UsagePageQueryModel,
ReturnPageQueryModel, ReturnPageQueryModel,