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:
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import CustomerDetailModel, CustomerListItemModel
|
||||
from module_business.entity.vo.master_vo import CustomerModel, CustomerPageQueryModel, DeleteCustomerModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
customer_controller = APIRouterPro(
|
||||
@@ -27,7 +29,7 @@ customer_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取客户分页列表接口',
|
||||
description='用于获取客户分页列表',
|
||||
response_model=PageResponseModel[CustomerModel],
|
||||
response_model=PageResponseModel[CustomerListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:customer:list')],
|
||||
)
|
||||
async def get_master_customer_list(
|
||||
@@ -37,6 +39,9 @@ async def get_master_customer_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
customer_page_query_result = await MasterService.list_customer(query_db, customer_page_query, scope, is_page=True)
|
||||
customer_page_query_result = await MasterSummaryService.enrich_page(
|
||||
query_db, 'customer', customer_page_query_result, scope
|
||||
)
|
||||
|
||||
return ResponseUtil.success(model_content=customer_page_query_result)
|
||||
|
||||
@@ -45,7 +50,7 @@ async def get_master_customer_list(
|
||||
'/{customer_id}',
|
||||
summary='获取客户详情接口',
|
||||
description='用于获取指定客户的详细信息',
|
||||
response_model=DataResponseModel[CustomerModel],
|
||||
response_model=DataResponseModel[CustomerDetailModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:customer:query')],
|
||||
)
|
||||
async def query_detail_master_customer(
|
||||
@@ -55,6 +60,7 @@ async def query_detail_master_customer(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
customer_detail_result = await MasterService.detail_customer(query_db, customer_id, scope)
|
||||
customer_detail_result = await MasterSummaryService.customer_detail(query_db, customer_detail_result, scope)
|
||||
|
||||
return ResponseUtil.success(data=customer_detail_result)
|
||||
|
||||
|
||||
@@ -15,8 +15,14 @@ from module_business.dependency.test_batch_dependency import TestBatchScopeDepen
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingBatchDetailModel,
|
||||
FillingBatchDetailQueryModel,
|
||||
FillingBatchModel,
|
||||
FillingBatchPageQueryModel,
|
||||
FillingCreateModel,
|
||||
FillingListItemModel,
|
||||
FillingModel,
|
||||
FillingOverviewModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
@@ -34,7 +40,7 @@ filling_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取托盘装填记录分页列表接口',
|
||||
description='按当前用户仓库数据范围获取托盘装填记录分页列表',
|
||||
response_model=PageResponseModel[FillingModel],
|
||||
response_model=PageResponseModel[FillingListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:list')],
|
||||
)
|
||||
async def get_business_filling_list(
|
||||
@@ -54,6 +60,64 @@ async def get_business_filling_list(
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/summary',
|
||||
summary='获取工厂装填概况接口',
|
||||
description='按当前用户仓库数据范围统计今日与本月装填、可装填空托盘和临期仍在仓的托盘',
|
||||
response_model=DataResponseModel[FillingOverviewModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:list')],
|
||||
)
|
||||
async def get_business_filling_summary(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.get_filling_overview_services(query_db, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/batches',
|
||||
summary='获取生产批次装填汇总分页列表接口',
|
||||
description='按当前用户仓库数据范围,将装填记录按菌种与生产批次聚合,最近装填的批次在前',
|
||||
response_model=PageResponseModel[FillingBatchModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:list')],
|
||||
)
|
||||
async def get_business_filling_batches(
|
||||
request: Request,
|
||||
batch_page_query: Annotated[FillingBatchPageQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.get_filling_batches_services(query_db, batch_page_query, current_user, test_scope)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/batches/detail',
|
||||
summary='获取生产批次详情接口',
|
||||
description='按当前用户仓库数据范围返回批次汇总、托盘当前去向与最近装填记录',
|
||||
response_model=DataResponseModel[FillingBatchDetailModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
|
||||
)
|
||||
async def get_business_filling_batch_detail(
|
||||
request: Request,
|
||||
batch_detail_query: Annotated[FillingBatchDetailQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.get_filling_batch_detail_services(
|
||||
query_db,
|
||||
batch_detail_query,
|
||||
current_user,
|
||||
test_scope,
|
||||
)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/strain-options',
|
||||
summary='获取装填菌种选项接口',
|
||||
|
||||
@@ -17,7 +17,9 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
InboundCreateModel,
|
||||
InboundListItemModel,
|
||||
InboundModel,
|
||||
InboundOverviewModel,
|
||||
InboundPageQueryModel,
|
||||
InboundScanQueryModel,
|
||||
InboundUpdateModel,
|
||||
@@ -37,7 +39,7 @@ inbound_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取首次入库单分页列表接口',
|
||||
description='按当前用户仓库数据范围获取首次入库单分页列表',
|
||||
response_model=PageResponseModel[InboundModel],
|
||||
response_model=PageResponseModel[InboundListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
|
||||
)
|
||||
async def get_business_inbound_list(
|
||||
@@ -57,6 +59,23 @@ async def get_business_inbound_list(
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@inbound_controller.get(
|
||||
'/summary',
|
||||
summary='获取首次入库概况接口',
|
||||
description='按当前用户仓库数据范围统计待确认草稿、本月入库、待首次入库托盘与仓库空托盘',
|
||||
response_model=DataResponseModel[InboundOverviewModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
|
||||
)
|
||||
async def get_business_inbound_summary(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await InboundService.get_inbound_overview_services(query_db, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@inbound_controller.get(
|
||||
'/warehouse-options',
|
||||
summary='获取首次入库仓库选项接口',
|
||||
|
||||
@@ -14,18 +14,40 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import LineListItemModel, MasterParentOptionModel
|
||||
from module_business.entity.vo.master_vo import DeleteLineModel, LineModel, LinePageQueryModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
line_controller = APIRouterPro(prefix='/master/line', tags=['基础资料-生产线管理'], dependencies=[PreAuthDependency()])
|
||||
# 上级资料选项单独注册且排在详情路由之前,避免被 /{line_id} 捕获
|
||||
line_option_controller = APIRouterPro(
|
||||
prefix='/master/line', order_num=99, tags=['基础资料-生产线管理'], dependencies=[PreAuthDependency()]
|
||||
)
|
||||
|
||||
|
||||
@line_option_controller.get(
|
||||
'/site-options',
|
||||
summary='获取产线可选现场接口',
|
||||
description='新增或修改产线时选择所属现场,只返回状态正常的现场',
|
||||
response_model=DataResponseModel[list[MasterParentOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('master:line:list')],
|
||||
)
|
||||
async def get_master_line_site_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
options = await MasterSummaryService.site_options(query_db, scope)
|
||||
return ResponseUtil.success(data=options)
|
||||
|
||||
|
||||
@line_controller.get(
|
||||
'/list',
|
||||
summary='获取生产线分页列表接口',
|
||||
description='用于获取生产线分页列表',
|
||||
response_model=PageResponseModel[LineModel],
|
||||
response_model=PageResponseModel[LineListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:line:list')],
|
||||
)
|
||||
async def get_master_line_list(
|
||||
@@ -35,6 +57,7 @@ async def get_master_line_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
line_page_query_result = await MasterService.list_line(query_db, line_page_query, scope, is_page=True)
|
||||
line_page_query_result = await MasterSummaryService.enrich_page(query_db, 'line', line_page_query_result, scope)
|
||||
|
||||
return ResponseUtil.success(model_content=line_page_query_result)
|
||||
|
||||
|
||||
@@ -88,7 +88,14 @@ async def _import_excel(
|
||||
db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
preview: bool = False,
|
||||
) -> Response:
|
||||
if preview:
|
||||
preview_result = await MasterExcelService.preview_import(
|
||||
db, resource, file, update_support, current_user, scope
|
||||
)
|
||||
logger.info('导入校验完成')
|
||||
return ResponseUtil.success(msg='校验完成,尚未写入', data=preview_result)
|
||||
result = await MasterExcelService.import_resource(
|
||||
db, resource, file, update_support, current_user, scope
|
||||
)
|
||||
@@ -127,8 +134,9 @@ async def import_master_strain_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> Response:
|
||||
return await _import_excel('strain', file, update_support, query_db, current_user, scope)
|
||||
return await _import_excel('strain', file, update_support, query_db, current_user, scope, preview)
|
||||
|
||||
|
||||
@strain_excel_controller.post(
|
||||
@@ -176,8 +184,9 @@ async def import_master_customer_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> Response:
|
||||
return await _import_excel('customer', file, update_support, query_db, current_user, scope)
|
||||
return await _import_excel('customer', file, update_support, query_db, current_user, scope, preview)
|
||||
|
||||
|
||||
@customer_excel_controller.post(
|
||||
@@ -225,8 +234,9 @@ async def import_master_site_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> Response:
|
||||
return await _import_excel('site', file, update_support, query_db, current_user, scope)
|
||||
return await _import_excel('site', file, update_support, query_db, current_user, scope, preview)
|
||||
|
||||
|
||||
@site_excel_controller.post(
|
||||
@@ -274,8 +284,9 @@ async def import_master_line_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> Response:
|
||||
return await _import_excel('line', file, update_support, query_db, current_user, scope)
|
||||
return await _import_excel('line', file, update_support, query_db, current_user, scope, preview)
|
||||
|
||||
|
||||
@line_excel_controller.post(
|
||||
@@ -323,8 +334,9 @@ async def import_master_warehouse_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> Response:
|
||||
return await _import_excel('warehouse', file, update_support, query_db, current_user, scope)
|
||||
return await _import_excel('warehouse', file, update_support, query_db, current_user, scope, preview)
|
||||
|
||||
|
||||
@warehouse_excel_controller.post(
|
||||
@@ -372,8 +384,9 @@ async def import_master_pallet_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> Response:
|
||||
return await _import_excel('pallet', file, update_support, query_db, current_user, scope)
|
||||
return await _import_excel('pallet', file, update_support, query_db, current_user, scope, preview)
|
||||
|
||||
|
||||
@pallet_excel_controller.post(
|
||||
@@ -421,8 +434,9 @@ async def import_master_tunnel_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> Response:
|
||||
return await _import_excel('tunnel', file, update_support, query_db, current_user, scope)
|
||||
return await _import_excel('tunnel', file, update_support, query_db, current_user, scope, preview)
|
||||
|
||||
|
||||
@tunnel_excel_controller.post(
|
||||
|
||||
@@ -13,13 +13,14 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import PalletDetailModel, PalletListItemModel
|
||||
from module_business.entity.vo.master_vo import (
|
||||
DeletePalletModel,
|
||||
PalletCreateModel,
|
||||
PalletEditModel,
|
||||
PalletModel,
|
||||
PalletPageQueryModel,
|
||||
)
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from module_business.service.pallet_service import PalletService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
@@ -32,7 +33,7 @@ pallet_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取托盘分页列表接口',
|
||||
description='用于获取托盘分页列表',
|
||||
response_model=PageResponseModel[PalletModel],
|
||||
response_model=PageResponseModel[PalletListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:pallet:list')],
|
||||
)
|
||||
async def get_master_pallet_list(
|
||||
@@ -42,6 +43,7 @@ async def get_master_pallet_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await PalletService.list_pallet(query_db, pallet_page_query, scope, is_page=True)
|
||||
result = await MasterSummaryService.enrich_page(query_db, 'pallet', result, scope)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@@ -67,7 +69,7 @@ async def query_master_pallet_qrcode(
|
||||
'/{pallet_id}',
|
||||
summary='获取托盘详情接口',
|
||||
description='用于获取指定托盘的详细信息',
|
||||
response_model=DataResponseModel[PalletModel],
|
||||
response_model=DataResponseModel[PalletDetailModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:pallet:query')],
|
||||
)
|
||||
async def query_detail_master_pallet(
|
||||
@@ -77,6 +79,7 @@ async def query_detail_master_pallet(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await PalletService.detail_pallet(query_db, pallet_id, scope)
|
||||
result = await MasterSummaryService.pallet_detail(query_db, result, scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
|
||||
@@ -19,10 +19,13 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
ReturnConfirmModel,
|
||||
ReturnListItemModel,
|
||||
ReturnModel,
|
||||
ReturnOverviewModel,
|
||||
ReturnPageQueryModel,
|
||||
ReturnRequestModel,
|
||||
ReturnScanQueryModel,
|
||||
ReturnSiteOptionModel,
|
||||
)
|
||||
from module_business.service.return_service import ReturnService
|
||||
from utils.response_util import ResponseUtil
|
||||
@@ -38,7 +41,7 @@ return_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取托盘退回单分页列表接口',
|
||||
description='按当前用户来源现场或目标仓库数据范围获取退回单',
|
||||
response_model=PageResponseModel[ReturnModel],
|
||||
response_model=PageResponseModel[ReturnListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:return:list')],
|
||||
)
|
||||
async def get_business_return_list(
|
||||
@@ -58,6 +61,40 @@ async def get_business_return_list(
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@return_controller.get(
|
||||
'/summary',
|
||||
summary='获取托盘退回概况接口',
|
||||
description='按当前用户来源现场或目标仓库数据范围统计待核对退回、退回中托盘与本月回仓去向',
|
||||
response_model=DataResponseModel[ReturnOverviewModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:return:list')],
|
||||
)
|
||||
async def get_business_return_summary(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ReturnService.get_return_overview_services(query_db, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@return_controller.get(
|
||||
'/site-options',
|
||||
summary='获取退回来源现场选项接口',
|
||||
description='返回当前用户授权的现场及其可见退回单涉及的来源现场,用于筛选与显示名称',
|
||||
response_model=DataResponseModel[list[ReturnSiteOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:return:list')],
|
||||
)
|
||||
async def get_business_return_site_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ReturnService.get_site_options_services(query_db, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@return_controller.get(
|
||||
'/warehouse-options',
|
||||
summary='获取退回目标仓库选项接口',
|
||||
|
||||
@@ -18,6 +18,7 @@ from module_business.entity.vo.operation_vo import (
|
||||
ConfirmCommandModel,
|
||||
PalletScanResponseModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentListItemModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
@@ -39,7 +40,7 @@ shipment_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取整托发货单分页列表接口',
|
||||
description='按当前用户仓库与现场双数据范围获取整托发货单',
|
||||
response_model=PageResponseModel[ShipmentModel],
|
||||
response_model=PageResponseModel[ShipmentListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:list')],
|
||||
)
|
||||
async def get_business_shipment_list(
|
||||
|
||||
@@ -14,6 +14,7 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import MasterParentOptionModel, SiteListItemModel
|
||||
from module_business.entity.vo.master_vo import (
|
||||
BusinessUserOptionModel,
|
||||
DeleteSiteModel,
|
||||
@@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import (
|
||||
UserAssignModel,
|
||||
)
|
||||
from module_business.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from module_business.service.pallet_service import PalletService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
@@ -31,6 +33,26 @@ ASSIGN_CURRENT_USER = CurrentUserDependency
|
||||
site_controller = APIRouterPro(
|
||||
prefix='/master/site', tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()]
|
||||
)
|
||||
# 上级资料选项单独注册且排在详情路由之前,避免被 /{site_id} 捕获
|
||||
site_option_controller = APIRouterPro(
|
||||
prefix='/master/site', order_num=99, tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()]
|
||||
)
|
||||
|
||||
|
||||
@site_option_controller.get(
|
||||
'/customer-options',
|
||||
summary='获取客户现场可选客户接口',
|
||||
description='新增或修改客户现场时选择所属客户,只返回状态正常的客户',
|
||||
response_model=DataResponseModel[list[MasterParentOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('master:site:list')],
|
||||
)
|
||||
async def get_master_site_customer_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
options = await MasterSummaryService.customer_options(query_db, scope)
|
||||
return ResponseUtil.success(data=options)
|
||||
site_user_controller = APIRouterPro(
|
||||
prefix='/master/site', tags=['基础资料-客户现场用户分配'], dependencies=[PreAuthDependency()]
|
||||
)
|
||||
@@ -77,7 +99,7 @@ async def assign_master_site_users(
|
||||
'/list',
|
||||
summary='获取客户现场分页列表接口',
|
||||
description='用于获取客户现场分页列表',
|
||||
response_model=PageResponseModel[SiteModel],
|
||||
response_model=PageResponseModel[SiteListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:site:list')],
|
||||
)
|
||||
async def get_master_site_list(
|
||||
@@ -87,6 +109,7 @@ async def get_master_site_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
site_page_query_result = await MasterService.list_site(query_db, site_page_query, scope, is_page=True)
|
||||
site_page_query_result = await MasterSummaryService.enrich_page(query_db, 'site', site_page_query_result, scope)
|
||||
|
||||
return ResponseUtil.success(model_content=site_page_query_result)
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import StrainListItemModel
|
||||
from module_business.entity.vo.master_vo import DeleteStrainModel, StrainModel, StrainPageQueryModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
strain_controller = APIRouterPro(
|
||||
@@ -27,7 +29,7 @@ strain_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取菌种分页列表接口',
|
||||
description='用于获取菌种分页列表',
|
||||
response_model=PageResponseModel[StrainModel],
|
||||
response_model=PageResponseModel[StrainListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:strain:list')],
|
||||
)
|
||||
async def get_master_strain_list(
|
||||
@@ -37,6 +39,9 @@ async def get_master_strain_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
strain_page_query_result = await MasterService.list_strain(query_db, strain_page_query, scope, is_page=True)
|
||||
strain_page_query_result = await MasterSummaryService.enrich_page(
|
||||
query_db, 'strain', strain_page_query_result, scope
|
||||
)
|
||||
|
||||
return ResponseUtil.success(model_content=strain_page_query_result)
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import MasterParentOptionModel, TunnelListItemModel
|
||||
from module_business.entity.vo.master_vo import DeleteTunnelModel, TunnelModel, TunnelPageQueryModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
tunnel_controller = APIRouterPro(
|
||||
@@ -23,13 +25,33 @@ tunnel_controller = APIRouterPro(
|
||||
tags=['基础资料-隧道管理'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
# 上级资料选项单独注册且排在详情路由之前,避免被 /{tunnel_id} 捕获
|
||||
tunnel_option_controller = APIRouterPro(
|
||||
prefix='/master/tunnel', order_num=99, tags=['基础资料-隧道管理'], dependencies=[PreAuthDependency()]
|
||||
)
|
||||
|
||||
|
||||
@tunnel_option_controller.get(
|
||||
'/site-options',
|
||||
summary='获取隧道可选现场接口',
|
||||
description='新增或修改隧道时选择所属现场,只返回状态正常的现场',
|
||||
response_model=DataResponseModel[list[MasterParentOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:list')],
|
||||
)
|
||||
async def get_master_tunnel_site_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
options = await MasterSummaryService.site_options(query_db, scope)
|
||||
return ResponseUtil.success(data=options)
|
||||
|
||||
|
||||
@tunnel_controller.get(
|
||||
'/list',
|
||||
summary='获取隧道分页列表接口',
|
||||
description='用于获取客户现场隧道分页列表',
|
||||
response_model=PageResponseModel[TunnelModel],
|
||||
response_model=PageResponseModel[TunnelListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:list')],
|
||||
)
|
||||
async def get_master_tunnel_list(
|
||||
@@ -39,6 +61,7 @@ async def get_master_tunnel_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await MasterService.list_tunnel(query_db, tunnel_page_query, scope, is_page=True)
|
||||
result = await MasterSummaryService.enrich_page(query_db, 'tunnel', result, scope)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
|
||||
@@ -21,8 +21,11 @@ from module_business.entity.vo.operation_vo import (
|
||||
TunnelEntryCreateModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageListItemModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
UsageSiteBoardModel,
|
||||
UsageSiteOptionModel,
|
||||
UsageSuccessResponseModel,
|
||||
)
|
||||
from module_business.service.usage_service import UsageService
|
||||
@@ -39,7 +42,7 @@ usage_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取客户现场使用记录分页列表接口',
|
||||
description='按当前用户客户现场数据范围获取使用记录',
|
||||
response_model=PageResponseModel[UsageModel],
|
||||
response_model=PageResponseModel[UsageListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:usage:list')],
|
||||
)
|
||||
async def get_business_usage_list(
|
||||
@@ -95,6 +98,41 @@ async def get_business_usage_tunnel_options(
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@usage_controller.get(
|
||||
'/site-options',
|
||||
summary='获取客户现场使用的现场选项接口',
|
||||
description='返回当前用户有权访问且已启用的客户现场,附带启用隧道与产线数',
|
||||
response_model=DataResponseModel[list[UsageSiteOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:usage:list')],
|
||||
)
|
||||
async def get_business_usage_site_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await UsageService.get_site_options_services(query_db, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@usage_controller.get(
|
||||
'/site-board',
|
||||
summary='获取客户现场看板接口',
|
||||
description='统计指定现场的托盘库存、今日用量、隧道使用与跳过先到托盘次数;不返回内部溯源号',
|
||||
response_model=DataResponseModel[UsageSiteBoardModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
|
||||
)
|
||||
async def get_business_usage_site_board(
|
||||
request: Request,
|
||||
site_id: Annotated[int, Query(alias='siteId', gt=0, description='客户现场ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await UsageService.get_site_board_services(query_db, site_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@usage_controller.post(
|
||||
'/enter-tunnel',
|
||||
summary='扫码入隧接口',
|
||||
|
||||
@@ -13,18 +13,22 @@ from common.vo import DataResponseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoQueueModel, UsageFifoRecommendationModel
|
||||
from module_business.service.usage_fifo_service import UsageFifoService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
usage_fifo_controller = APIRouterPro(
|
||||
prefix='/business/usage-fifo',
|
||||
tags=['业务管理-客户现场使用'],
|
||||
dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('business:usage:query')],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@usage_fifo_controller.get('/recommendation', response_model=DataResponseModel[UsageFifoRecommendationModel])
|
||||
@usage_fifo_controller.get(
|
||||
'/recommendation',
|
||||
response_model=DataResponseModel[UsageFifoRecommendationModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
|
||||
)
|
||||
async def get_usage_fifo_recommendation(
|
||||
request: Request,
|
||||
qr_token: Annotated[str, Query(alias='qrToken', min_length=1, max_length=128, pattern=r'\S')],
|
||||
@@ -37,3 +41,19 @@ async def get_usage_fifo_recommendation(
|
||||
query_db, qr_token, current_user, test_scope, tunnel_id=tunnel_id,
|
||||
)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@usage_fifo_controller.get(
|
||||
'/queue',
|
||||
response_model=DataResponseModel[UsageFifoQueueModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
|
||||
)
|
||||
async def get_usage_fifo_queue(
|
||||
request: Request,
|
||||
site_id: Annotated[int, Query(alias='siteId', gt=0, description='客户现场ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await UsageFifoService.queue(query_db, site_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
@@ -14,6 +14,7 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import WarehouseListItemModel
|
||||
from module_business.entity.vo.master_vo import (
|
||||
BusinessUserOptionModel,
|
||||
DeleteWarehouseModel,
|
||||
@@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import (
|
||||
WarehousePageQueryModel,
|
||||
)
|
||||
from module_business.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from module_business.service.pallet_service import PalletService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
@@ -77,7 +79,7 @@ async def assign_master_warehouse_users(
|
||||
'/list',
|
||||
summary='获取仓库分页列表接口',
|
||||
description='用于获取仓库分页列表',
|
||||
response_model=PageResponseModel[WarehouseModel],
|
||||
response_model=PageResponseModel[WarehouseListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:warehouse:list')],
|
||||
)
|
||||
async def get_master_warehouse_list(
|
||||
@@ -92,6 +94,9 @@ async def get_master_warehouse_list(
|
||||
scope,
|
||||
is_page=True,
|
||||
)
|
||||
warehouse_page_query_result = await MasterSummaryService.enrich_page(
|
||||
query_db, 'warehouse', warehouse_page_query_result, scope
|
||||
)
|
||||
|
||||
return ResponseUtil.success(model_content=warehouse_page_query_result)
|
||||
|
||||
|
||||
@@ -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 pydantic import BaseModel
|
||||
from sqlalchemy import ColumnElement, Select, func, select, update
|
||||
from sqlalchemy import ColumnElement, Select, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_admin.entity.do.user_do import SysUser
|
||||
from module_business.domain.enums import PalletFlowState
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
|
||||
from module_business.entity.do.master_do import (
|
||||
BizCustomer,
|
||||
@@ -39,6 +40,18 @@ from module_business.entity.vo.master_vo import (
|
||||
)
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
_PALLET_FLOW_GROUP_STATES = {
|
||||
'unstocked': (PalletFlowState.UNSTOCKED.value,),
|
||||
'warehouse': (PalletFlowState.EMPTY_IN_WAREHOUSE.value, PalletFlowState.FILLED_IN_WAREHOUSE.value),
|
||||
'transit': (PalletFlowState.IN_TRANSIT.value,),
|
||||
'site': (
|
||||
PalletFlowState.AT_SITE_UNUSED.value,
|
||||
PalletFlowState.AT_SITE_PARTIAL.value,
|
||||
PalletFlowState.AT_SITE_EMPTY.value,
|
||||
),
|
||||
'returning': (PalletFlowState.RETURNING.value,),
|
||||
}
|
||||
|
||||
|
||||
class MasterDao:
|
||||
"""菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。"""
|
||||
@@ -494,15 +507,44 @@ class MasterDao:
|
||||
) -> PageModel | list[dict[str, Any]]:
|
||||
conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)]
|
||||
if query_object.pallet_code:
|
||||
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
|
||||
# 扫码枪输入的是二维码固定标识,按编号模糊或标识精确匹配
|
||||
conditions.append(
|
||||
or_(
|
||||
BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'),
|
||||
BizPallet.qr_token == query_object.pallet_code,
|
||||
)
|
||||
)
|
||||
if query_object.asset_status:
|
||||
conditions.append(BizPallet.asset_status == query_object.asset_status)
|
||||
if query_object.flow_group:
|
||||
conditions.append(cls._pallet_flow_group_condition(query_object.flow_group, scope))
|
||||
ids = cls._parse_id_list(getattr(query_object, 'ids', None))
|
||||
if ids:
|
||||
conditions.append(BizPallet.pallet_id.in_(ids))
|
||||
statement = select(BizPallet).where(*conditions).order_by(BizPallet.pallet_id)
|
||||
return await cls._paginate(db, statement, query_object, is_page)
|
||||
|
||||
@staticmethod
|
||||
def _pallet_flow_group_condition(flow_group: str, scope: TestBatchScope) -> ColumnElement[bool]:
|
||||
states = _PALLET_FLOW_GROUP_STATES[flow_group]
|
||||
in_states = BizPallet.pallet_id.in_(
|
||||
select(BizPalletInventory.pallet_id).where(
|
||||
BizPalletInventory.flow_state.in_(states),
|
||||
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
|
||||
)
|
||||
)
|
||||
if flow_group != 'unstocked':
|
||||
return in_states
|
||||
# 历史托盘可能缺少库存行,同样视为未入库
|
||||
return or_(
|
||||
in_states,
|
||||
BizPallet.pallet_id.not_in(
|
||||
select(BizPalletInventory.pallet_id).where(
|
||||
batch_scope_condition(BizPalletInventory.test_batch_id, scope)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_pallet(
|
||||
cls,
|
||||
|
||||
@@ -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.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_business.domain.enums import DocumentStatus, PalletFlowState
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizWarehouse
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord,
|
||||
BizInboundItem,
|
||||
@@ -608,6 +609,15 @@ class OperationDao:
|
||||
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
|
||||
if query_object.pallet_code:
|
||||
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
|
||||
if query_object.keyword:
|
||||
keyword = f'%{query_object.keyword}%'
|
||||
conditions.append(
|
||||
or_(
|
||||
BizFillingRecord.filling_no.like(keyword),
|
||||
BizPallet.pallet_code.like(keyword),
|
||||
BizFillingRecord.batch_no.like(keyword),
|
||||
)
|
||||
)
|
||||
conditions.extend(
|
||||
cls._time_conditions(
|
||||
BizFillingRecord.operated_at,
|
||||
@@ -616,7 +626,7 @@ class OperationDao:
|
||||
)
|
||||
)
|
||||
statement = select(BizFillingRecord)
|
||||
if query_object.pallet_code:
|
||||
if query_object.pallet_code or query_object.keyword:
|
||||
statement = statement.join(
|
||||
BizPallet,
|
||||
BizPallet.pallet_id == BizFillingRecord.pallet_id,
|
||||
@@ -680,6 +690,10 @@ class OperationDao:
|
||||
conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id)
|
||||
if query_object.status:
|
||||
conditions.append(BizShipmentOrder.status == query_object.status.value)
|
||||
if query_object.receipt_state:
|
||||
# 收货状态只在确认后有意义,草稿的默认值不参与筛选
|
||||
conditions.append(BizShipmentOrder.status == DocumentStatus.CONFIRMED.value)
|
||||
conditions.append(BizShipmentOrder.receipt_state == query_object.receipt_state)
|
||||
conditions.extend(
|
||||
cls._time_conditions(
|
||||
BizShipmentOrder.create_time,
|
||||
@@ -687,7 +701,45 @@ class OperationDao:
|
||||
query_object.end_time,
|
||||
)
|
||||
)
|
||||
statement = select(BizShipmentOrder).where(*conditions).order_by(BizShipmentOrder.shipment_id)
|
||||
statement = select(BizShipmentOrder)
|
||||
if query_object.keyword:
|
||||
keyword = f'%{query_object.keyword}%'
|
||||
source_warehouse = aliased(BizWarehouse, name='source_warehouse')
|
||||
target_site = aliased(BizCustomerSite, name='target_site')
|
||||
target_customer = aliased(BizCustomer, name='target_customer')
|
||||
# 名称关联与单据处于同一测试批次范围,跨批次的主数据不会被匹配
|
||||
statement = (
|
||||
statement.outerjoin(
|
||||
source_warehouse,
|
||||
and_(
|
||||
source_warehouse.warehouse_id == BizShipmentOrder.source_warehouse_id,
|
||||
batch_scope_condition(source_warehouse.test_batch_id, scope),
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
target_site,
|
||||
and_(
|
||||
target_site.site_id == BizShipmentOrder.target_site_id,
|
||||
batch_scope_condition(target_site.test_batch_id, scope),
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
target_customer,
|
||||
and_(
|
||||
target_customer.customer_id == target_site.customer_id,
|
||||
batch_scope_condition(target_customer.test_batch_id, scope),
|
||||
),
|
||||
)
|
||||
)
|
||||
conditions.append(
|
||||
or_(
|
||||
BizShipmentOrder.shipment_no.like(keyword),
|
||||
source_warehouse.warehouse_name.like(keyword),
|
||||
target_site.site_name.like(keyword),
|
||||
target_customer.customer_name.like(keyword),
|
||||
)
|
||||
)
|
||||
statement = statement.where(*conditions).order_by(BizShipmentOrder.shipment_id)
|
||||
return await cls._paginate(db, statement, query_object, is_page)
|
||||
|
||||
@classmethod
|
||||
@@ -818,6 +870,11 @@ class OperationDao:
|
||||
conditions.append(BizUsageRecord.line_id == query_object.line_id)
|
||||
if query_object.pallet_code:
|
||||
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
|
||||
if query_object.keyword:
|
||||
keyword = f'%{query_object.keyword}%'
|
||||
conditions.append(or_(BizUsageRecord.usage_no.like(keyword), BizPallet.pallet_code.like(keyword)))
|
||||
if query_object.fifo_skipped:
|
||||
conditions.append(BizUsageRecord.fifo_skip_reason.is_not(None))
|
||||
conditions.extend(
|
||||
cls._time_conditions(
|
||||
BizUsageRecord.occurred_at,
|
||||
@@ -826,7 +883,7 @@ class OperationDao:
|
||||
)
|
||||
)
|
||||
statement = select(BizUsageRecord)
|
||||
if query_object.pallet_code:
|
||||
if query_object.pallet_code or query_object.keyword:
|
||||
statement = statement.join(
|
||||
BizPallet,
|
||||
BizPallet.pallet_id == BizUsageRecord.pallet_id,
|
||||
@@ -884,7 +941,63 @@ class OperationDao:
|
||||
query_object.end_time,
|
||||
)
|
||||
)
|
||||
statement = select(BizReturnOrder).where(*conditions).order_by(BizReturnOrder.return_id)
|
||||
statement = select(BizReturnOrder)
|
||||
if query_object.keyword:
|
||||
keyword = f'%{query_object.keyword}%'
|
||||
source_site = aliased(BizCustomerSite, name='source_site')
|
||||
source_customer = aliased(BizCustomer, name='source_customer')
|
||||
target_warehouse = aliased(BizWarehouse, name='target_warehouse')
|
||||
# 名称关联与单据处于同一测试批次范围,跨批次的主数据不会被匹配
|
||||
statement = (
|
||||
statement.outerjoin(
|
||||
source_site,
|
||||
and_(
|
||||
source_site.site_id == BizReturnOrder.source_site_id,
|
||||
batch_scope_condition(source_site.test_batch_id, scope),
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
source_customer,
|
||||
and_(
|
||||
source_customer.customer_id == source_site.customer_id,
|
||||
batch_scope_condition(source_customer.test_batch_id, scope),
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
target_warehouse,
|
||||
and_(
|
||||
target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id,
|
||||
batch_scope_condition(target_warehouse.test_batch_id, scope),
|
||||
),
|
||||
)
|
||||
)
|
||||
# 托盘编号按明细存在性匹配,不改变每张单据只出现一行
|
||||
pallet_match = (
|
||||
select(BizReturnItem.return_item_id)
|
||||
.join(
|
||||
BizPallet,
|
||||
and_(
|
||||
BizPallet.pallet_id == BizReturnItem.pallet_id,
|
||||
batch_scope_condition(BizPallet.test_batch_id, scope),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
BizReturnItem.return_id == BizReturnOrder.return_id,
|
||||
batch_scope_condition(BizReturnItem.test_batch_id, scope),
|
||||
BizPallet.pallet_code.like(keyword),
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
conditions.append(
|
||||
or_(
|
||||
BizReturnOrder.return_no.like(keyword),
|
||||
source_site.site_name.like(keyword),
|
||||
source_customer.customer_name.like(keyword),
|
||||
target_warehouse.warehouse_name.like(keyword),
|
||||
pallet_match,
|
||||
)
|
||||
)
|
||||
statement = statement.where(*conditions).order_by(BizReturnOrder.return_id)
|
||||
return await cls._paginate(db, statement, query_object, is_page)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -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 sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy import ColumnElement, Select, and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
|
||||
|
||||
# 待使用队列每个菌种最多返回的托盘数;总数仍完整统计
|
||||
QUEUE_LIMIT_PER_STRAIN = 20
|
||||
|
||||
|
||||
class UsageFifoDao:
|
||||
@staticmethod
|
||||
def _candidate_conditions(
|
||||
*,
|
||||
site_id: int,
|
||||
strain_id: int | None,
|
||||
tunnel_id: int | None,
|
||||
trace_no: str | None,
|
||||
scope: TestBatchScope,
|
||||
) -> list[ColumnElement[bool]]:
|
||||
"""建议与待使用队列共用的候选条件;strain_id 为空时不按菌种限制(仅队列使用)。"""
|
||||
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
|
||||
unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None))
|
||||
conditions = [
|
||||
inventory.site_id == site_id,
|
||||
inventory.warehouse_id.is_(None),
|
||||
inventory.flow_state.in_(['AT_SITE_UNUSED', 'AT_SITE_PARTIAL']),
|
||||
inventory.remaining_packages > 0,
|
||||
inventory.remaining_packages <= pallet.capacity_packages,
|
||||
]
|
||||
if strain_id is not None:
|
||||
conditions.append(filling.strain_id == strain_id)
|
||||
conditions.extend(
|
||||
[
|
||||
filling.active_slot == 'ACTIVE',
|
||||
filling.closed_at.is_(None),
|
||||
pallet.asset_status == '0',
|
||||
batch_scope_condition(inventory.test_batch_id, scope),
|
||||
batch_scope_condition(filling.test_batch_id, scope),
|
||||
batch_scope_condition(pallet.test_batch_id, scope),
|
||||
(
|
||||
unbound
|
||||
if tunnel_id is None
|
||||
else or_(unbound, and_(inventory.tunnel_id == tunnel_id, inventory.trace_no == trace_no))
|
||||
),
|
||||
]
|
||||
)
|
||||
return conditions
|
||||
|
||||
@staticmethod
|
||||
def _join_candidates(statement: Select) -> Select:
|
||||
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
|
||||
return (
|
||||
statement.select_from(inventory)
|
||||
.join(pallet, pallet.pallet_id == inventory.pallet_id)
|
||||
.join(
|
||||
filling,
|
||||
and_(filling.filling_id == inventory.active_filling_id, filling.pallet_id == inventory.pallet_id),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fifo_order() -> tuple[Any, ...]:
|
||||
# 先到先用:按实际收货时间,同一时间按托盘ID稳定排序
|
||||
return (BizPalletInventory.site_arrived_at, BizPalletInventory.pallet_id)
|
||||
|
||||
@classmethod
|
||||
async def recommendation(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
site_id: int,
|
||||
@@ -23,27 +82,10 @@ class UsageFifoDao:
|
||||
for_update: bool = False,
|
||||
) -> tuple[dict[str, Any] | None, int]:
|
||||
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
|
||||
unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None))
|
||||
conditions = [
|
||||
inventory.site_id == site_id,
|
||||
inventory.warehouse_id.is_(None),
|
||||
inventory.flow_state.in_(['AT_SITE_UNUSED', 'AT_SITE_PARTIAL']),
|
||||
inventory.remaining_packages > 0,
|
||||
inventory.remaining_packages <= pallet.capacity_packages,
|
||||
filling.strain_id == strain_id,
|
||||
filling.active_slot == 'ACTIVE',
|
||||
filling.closed_at.is_(None),
|
||||
pallet.asset_status == '0',
|
||||
batch_scope_condition(inventory.test_batch_id, scope),
|
||||
batch_scope_condition(filling.test_batch_id, scope),
|
||||
batch_scope_condition(pallet.test_batch_id, scope),
|
||||
(
|
||||
unbound
|
||||
if tunnel_id is None
|
||||
else or_(unbound, and_(inventory.tunnel_id == tunnel_id, inventory.trace_no == trace_no))
|
||||
),
|
||||
]
|
||||
base = (
|
||||
conditions = cls._candidate_conditions(
|
||||
site_id=site_id, strain_id=strain_id, tunnel_id=tunnel_id, trace_no=trace_no, scope=scope
|
||||
)
|
||||
base = cls._join_candidates(
|
||||
select(
|
||||
pallet.pallet_id,
|
||||
pallet.pallet_code,
|
||||
@@ -51,22 +93,8 @@ class UsageFifoDao:
|
||||
inventory.remaining_packages,
|
||||
inventory.site_arrived_at,
|
||||
)
|
||||
.select_from(inventory)
|
||||
.join(pallet, pallet.pallet_id == inventory.pallet_id)
|
||||
.join(
|
||||
filling,
|
||||
and_(filling.filling_id == inventory.active_filling_id, filling.pallet_id == inventory.pallet_id),
|
||||
)
|
||||
.where(*conditions)
|
||||
)
|
||||
statement = (
|
||||
base.where(inventory.site_arrived_at.is_not(None))
|
||||
.order_by(
|
||||
inventory.site_arrived_at,
|
||||
inventory.pallet_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).where(*conditions)
|
||||
statement = base.where(inventory.site_arrived_at.is_not(None)).order_by(*cls._fifo_order()).limit(1)
|
||||
if for_update:
|
||||
# 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。
|
||||
# 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。
|
||||
@@ -83,3 +111,88 @@ class UsageFifoDao:
|
||||
)
|
||||
)
|
||||
return (dict(first) if first is not None else None, unknown.scalar_one())
|
||||
|
||||
@classmethod
|
||||
async def queue(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
site_id: int,
|
||||
scope: TestBatchScope,
|
||||
limit_per_strain: int = QUEUE_LIMIT_PER_STRAIN,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""现场尚未入隧的待使用托盘,按菌种分组;组内顺序与先到先用建议一致(只读)。"""
|
||||
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
|
||||
conditions = cls._candidate_conditions(
|
||||
site_id=site_id, strain_id=None, tunnel_id=None, trace_no=None, scope=scope
|
||||
)
|
||||
known_statement = (
|
||||
cls._join_candidates(
|
||||
select(
|
||||
filling.strain_id,
|
||||
filling.strain_code_snapshot,
|
||||
filling.strain_name_snapshot,
|
||||
pallet.pallet_id,
|
||||
pallet.pallet_code,
|
||||
filling.batch_no,
|
||||
filling.expiry_date,
|
||||
inventory.remaining_packages,
|
||||
inventory.site_arrived_at,
|
||||
)
|
||||
)
|
||||
.where(*conditions, inventory.site_arrived_at.is_not(None))
|
||||
.order_by(*cls._fifo_order())
|
||||
)
|
||||
unknown_statement = (
|
||||
cls._join_candidates(
|
||||
select(
|
||||
filling.strain_id,
|
||||
func.max(filling.strain_code_snapshot),
|
||||
func.max(filling.strain_name_snapshot),
|
||||
func.count(inventory.pallet_id),
|
||||
)
|
||||
)
|
||||
.where(*conditions, inventory.site_arrived_at.is_(None))
|
||||
.group_by(filling.strain_id)
|
||||
)
|
||||
|
||||
groups: dict[int, dict[str, Any]] = {}
|
||||
|
||||
def group_for(strain_id: int, strain_code: str | None, strain_name: str | None) -> dict[str, Any]:
|
||||
group = groups.get(strain_id)
|
||||
if group is None:
|
||||
group = {
|
||||
'strain_id': strain_id,
|
||||
'strain_code': strain_code,
|
||||
'strain_name': strain_name,
|
||||
'pallet_count': 0,
|
||||
'unknown_arrival_count': 0,
|
||||
'pallets': [],
|
||||
}
|
||||
groups[strain_id] = group
|
||||
return group
|
||||
|
||||
for row in (await db.execute(known_statement)).mappings().all():
|
||||
group = group_for(row['strain_id'], row['strain_code_snapshot'], row['strain_name_snapshot'])
|
||||
group['pallet_count'] += 1
|
||||
if len(group['pallets']) < limit_per_strain:
|
||||
group['pallets'].append(
|
||||
{
|
||||
'pallet_id': row['pallet_id'],
|
||||
'pallet_code': row['pallet_code'],
|
||||
'batch_no': row['batch_no'],
|
||||
'expiry_date': row['expiry_date'],
|
||||
'remaining_packages': row['remaining_packages'],
|
||||
'site_arrived_at': row['site_arrived_at'],
|
||||
}
|
||||
)
|
||||
for strain_id, strain_code, strain_name, count in (await db.execute(unknown_statement)).all():
|
||||
group_for(strain_id, strain_code, strain_name)['unknown_arrival_count'] = int(count or 0)
|
||||
return sorted(
|
||||
groups.values(),
|
||||
key=lambda group: (
|
||||
-(group['pallet_count'] + group['unknown_arrival_count']),
|
||||
group['strain_name'] or '',
|
||||
group['strain_id'],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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='每页记录数')
|
||||
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
asset_status: Literal['0', '1'] | None = Field(default=None, description='资产状态')
|
||||
flow_group: Literal['unstocked', 'warehouse', 'transit', 'site', 'returning'] | None = Field(
|
||||
default=None, description='当前流转环节'
|
||||
)
|
||||
ids: str | None = Field(default=None, description='主键ID列表(英文逗号分隔)')
|
||||
|
||||
|
||||
|
||||
@@ -250,6 +250,8 @@ class InboundItemModel(BaseModel):
|
||||
inbound_item_id: int = Field(gt=0, description='入库明细ID')
|
||||
inbound_id: int = Field(gt=0, description='入库单ID')
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)')
|
||||
flow_state: PalletFlowState | None = Field(default=None, description='托盘当前流转状态(只读展示)')
|
||||
create_by: str | None = Field(default=None, max_length=64, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
|
||||
@@ -275,6 +277,44 @@ class InboundModel(BaseModel):
|
||||
items: list[InboundItemModel] = Field(default_factory=list, description='入库托盘明细')
|
||||
|
||||
|
||||
class InboundListItemModel(InboundModel):
|
||||
"""首次入库单列表行:在单据字段外附带只读的名称与托盘数。"""
|
||||
|
||||
warehouse_name: str | None = Field(default=None, max_length=100, description='入库仓库名称')
|
||||
creator_name: str | None = Field(default=None, max_length=30, description='创建人昵称')
|
||||
confirmer_name: str | None = Field(default=None, max_length=30, description='确认人昵称')
|
||||
pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数')
|
||||
|
||||
|
||||
class InboundWarehouseCountModel(BaseModel):
|
||||
"""按仓库统计的托盘数。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
warehouse_id: int = Field(gt=0, description='仓库ID')
|
||||
warehouse_name: str | None = Field(default=None, max_length=100, description='仓库名称')
|
||||
pallet_count: int = Field(strict=True, ge=0, description='托盘数')
|
||||
|
||||
|
||||
class InboundOverviewModel(BaseModel):
|
||||
"""首次入库页面概况(只读)。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
draft_count: int = Field(strict=True, ge=0, description='待确认草稿单数')
|
||||
draft_pallet_count: int = Field(strict=True, ge=0, description='待确认草稿托盘数')
|
||||
oldest_draft_at: datetime | None = Field(default=None, description='最早草稿创建时间')
|
||||
month_start: date = Field(description='本月统计起始日期')
|
||||
month_confirmed_count: int = Field(strict=True, ge=0, description='本月已确认单数')
|
||||
month_confirmed_pallet_count: int = Field(strict=True, ge=0, description='本月已确认入库托盘数')
|
||||
unstocked_pallet_count: int = Field(strict=True, ge=0, description='待首次入库托盘数(资产正常且未入库)')
|
||||
empty_pallet_count: int = Field(strict=True, ge=0, description='授权仓库空托盘总数')
|
||||
empty_by_warehouse: list[InboundWarehouseCountModel] = Field(
|
||||
default_factory=list,
|
||||
description='授权仓库空托盘按仓库分布,托盘数从多到少',
|
||||
)
|
||||
|
||||
|
||||
class FillingModel(BaseModel):
|
||||
"""托盘装填记录展示模型。"""
|
||||
|
||||
@@ -305,6 +345,68 @@ class FillingModel(BaseModel):
|
||||
remark: str | None = Field(default=None, max_length=500, description='备注')
|
||||
|
||||
|
||||
class FillingListItemModel(FillingModel):
|
||||
"""托盘装填记录列表行:在记录字段外附带只读的托盘编号、仓库与操作人名称。"""
|
||||
|
||||
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号')
|
||||
warehouse_name: str | None = Field(default=None, max_length=100, description='仓库名称')
|
||||
operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称')
|
||||
|
||||
|
||||
class FillingBatchModel(BaseModel):
|
||||
"""按菌种 + 生产批次聚合的装填汇总(只读)。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
strain_id: int = Field(gt=0, description='菌种ID')
|
||||
strain_code: str | None = Field(default=None, max_length=64, description='菌种编码快照')
|
||||
strain_name: str | None = Field(default=None, max_length=100, description='菌种名称快照')
|
||||
batch_no: str = Field(min_length=1, max_length=64, description='生产批次')
|
||||
production_date: date | None = Field(default=None, description='生产日期(批次内最早)')
|
||||
expiry_date: date | None = Field(default=None, description='有效期至(批次内最早)')
|
||||
package_spec: str | None = Field(default=None, max_length=64, description='最近一次装填的包装规格')
|
||||
pallet_count: int = Field(strict=True, ge=0, description='已装填托盘数')
|
||||
package_sum: int = Field(strict=True, ge=0, description='已装填包数')
|
||||
last_operated_at: datetime | None = Field(default=None, description='最近装填时间')
|
||||
last_operator_name: str | None = Field(default=None, max_length=30, description='最近装填操作人昵称')
|
||||
|
||||
|
||||
class FillingBatchStateCountModel(BaseModel):
|
||||
"""批次托盘按当前去向统计。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
state: str = Field(
|
||||
min_length=1,
|
||||
max_length=32,
|
||||
description='托盘当前流转状态;装填周期已结束(物料用完)时为 CONSUMED',
|
||||
)
|
||||
pallet_count: int = Field(strict=True, ge=0, description='托盘数')
|
||||
|
||||
|
||||
class FillingBatchRecordModel(BaseModel):
|
||||
"""批次内的一条装填记录(只读展示)。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
filling_id: int = Field(gt=0, description='装填记录ID')
|
||||
filling_no: str = Field(min_length=1, max_length=64, description='装填单号')
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号')
|
||||
warehouse_name: str | None = Field(default=None, max_length=100, description='装填仓库名称')
|
||||
operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称')
|
||||
operated_at: datetime = Field(description='装填时间')
|
||||
|
||||
|
||||
class FillingBatchDetailModel(FillingBatchModel):
|
||||
"""生产批次详情:批次汇总、托盘当前去向与最近装填记录。"""
|
||||
|
||||
distribution: list[FillingBatchStateCountModel] = Field(
|
||||
default_factory=list, description='托盘当前去向,托盘数从多到少'
|
||||
)
|
||||
recent: list[FillingBatchRecordModel] = Field(default_factory=list, description='最近 10 条装填记录')
|
||||
|
||||
|
||||
class ShipmentItemModel(BaseModel):
|
||||
"""整托发货明细模型。"""
|
||||
|
||||
@@ -321,6 +423,10 @@ class ShipmentItemModel(BaseModel):
|
||||
ge=0,
|
||||
description='当前库存剩余包数(只读,非确认快照)',
|
||||
)
|
||||
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)')
|
||||
strain_code: str | None = Field(default=None, max_length=64, description='菌种编码快照(只读展示)')
|
||||
strain_name: str | None = Field(default=None, max_length=100, description='菌种名称快照(只读展示)')
|
||||
batch_no: str | None = Field(default=None, max_length=64, description='生产批次(只读展示)')
|
||||
create_by: str | None = Field(default=None, max_length=64, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
|
||||
@@ -350,6 +456,61 @@ class ShipmentModel(BaseModel):
|
||||
items: list[ShipmentItemModel] = Field(default_factory=list, description='发货托盘明细')
|
||||
|
||||
|
||||
class ShipmentStrainSummaryModel(BaseModel):
|
||||
"""发货单内菌种构成。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
strain_name: str = Field(min_length=1, max_length=100, description='菌种名称或编码')
|
||||
pallet_count: int = Field(strict=True, gt=0, description='托盘数')
|
||||
|
||||
|
||||
class FillingOverviewModel(BaseModel):
|
||||
"""工厂装填页面概况(只读)。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
today_pallet_count: int = Field(strict=True, ge=0, description='今日装填托盘数')
|
||||
today_package_sum: int = Field(strict=True, ge=0, description='今日装填包数')
|
||||
today_batch_count: int = Field(strict=True, ge=0, description='今日涉及的生产批次数')
|
||||
month_start: date = Field(description='本月统计起始日期')
|
||||
month_pallet_count: int = Field(strict=True, ge=0, description='本月装填托盘数')
|
||||
month_batch_count: int = Field(strict=True, ge=0, description='本月涉及的生产批次数')
|
||||
month_top_strains: list[ShipmentStrainSummaryModel] = Field(
|
||||
default_factory=list,
|
||||
description='本月装填最多的菌种(最多 3 个)',
|
||||
)
|
||||
empty_pallet_count: int = Field(strict=True, ge=0, description='授权仓库可装填空托盘总数')
|
||||
empty_by_warehouse: list[InboundWarehouseCountModel] = Field(
|
||||
default_factory=list,
|
||||
description='授权仓库空托盘按仓库分布,托盘数从多到少',
|
||||
)
|
||||
near_expiry_days: int = Field(strict=True, gt=0, description='临期判定天数')
|
||||
near_expiry_pallet_count: int = Field(
|
||||
strict=True, ge=0, description='有效期在临期天数内(含已过期)且仍在仓的有料托盘数'
|
||||
)
|
||||
near_expiry_batch_count: int = Field(strict=True, ge=0, description='上述托盘涉及的生产批次数')
|
||||
nearest_expiry_batch_no: str | None = Field(default=None, max_length=64, description='最早到期的在仓批次')
|
||||
nearest_expiry_date: date | None = Field(default=None, description='最早到期的在仓批次有效期')
|
||||
|
||||
|
||||
class ShipmentListItemModel(ShipmentModel):
|
||||
"""整托发货单列表行:在单据字段外附带只读的名称与汇总。"""
|
||||
|
||||
source_warehouse_name: str | None = Field(default=None, max_length=100, description='来源仓库名称')
|
||||
target_site_name: str | None = Field(default=None, max_length=100, description='目标现场名称')
|
||||
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
|
||||
creator_name: str | None = Field(default=None, max_length=30, description='创建人昵称')
|
||||
pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数')
|
||||
package_qty: int | None = Field(
|
||||
default=None,
|
||||
strict=True,
|
||||
ge=0,
|
||||
description='包数:已确认为确认快照之和,草稿为当前可发余量之和',
|
||||
)
|
||||
strains: list[ShipmentStrainSummaryModel] = Field(default_factory=list, description='菌种构成')
|
||||
|
||||
|
||||
class UsageModel(BaseModel):
|
||||
"""客户现场使用记录展示模型。"""
|
||||
|
||||
@@ -373,6 +534,20 @@ class UsageModel(BaseModel):
|
||||
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
|
||||
|
||||
|
||||
class UsageListItemModel(UsageModel):
|
||||
"""客户现场使用记录列表行:附带只读的托盘、现场、隧道、产线与操作人名称。"""
|
||||
|
||||
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号')
|
||||
site_name: str | None = Field(default=None, max_length=100, description='现场名称')
|
||||
tunnel_code: str | None = Field(default=None, max_length=64, description='隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='隧道名称')
|
||||
line_name: str | None = Field(default=None, max_length=100, description='产线名称')
|
||||
operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称')
|
||||
skipped_pallet_code: str | None = Field(
|
||||
default=None, max_length=64, description='被跳过的先到托盘编号(取自使用时的先到先用快照)'
|
||||
)
|
||||
|
||||
|
||||
class ReturnItemModel(BaseModel):
|
||||
"""托盘退回明细模型。"""
|
||||
|
||||
@@ -386,6 +561,8 @@ class ReturnItemModel(BaseModel):
|
||||
trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时生产批次快照')
|
||||
remaining_packages_snapshot: int = Field(strict=True, ge=0, description='申请时剩余包数快照')
|
||||
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='申请时所在隧道名称(只读展示)')
|
||||
create_by: str | None = Field(default=None, max_length=64, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
|
||||
@@ -416,6 +593,37 @@ class ReturnModel(BaseModel):
|
||||
items: list[ReturnItemModel] = Field(default_factory=list, description='退回托盘明细')
|
||||
|
||||
|
||||
class ReturnListItemModel(ReturnModel):
|
||||
"""托盘退回单列表行:在单据字段外附带只读的名称与托盘汇总。"""
|
||||
|
||||
source_site_name: str | None = Field(default=None, max_length=100, description='来源现场名称')
|
||||
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
|
||||
target_warehouse_name: str | None = Field(default=None, max_length=100, description='目标仓库名称')
|
||||
requester_name: str | None = Field(default=None, max_length=30, description='申请人昵称')
|
||||
confirmer_name: str | None = Field(default=None, max_length=30, description='确认人昵称')
|
||||
pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数')
|
||||
package_sum: int = Field(default=0, strict=True, ge=0, description='申请时剩余包数快照之和')
|
||||
filled_count: int = Field(default=0, strict=True, ge=0, description='申请时仍有余量的托盘数')
|
||||
|
||||
|
||||
class ReturnOverviewModel(BaseModel):
|
||||
"""托盘退回页面概况(只读)。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
pending_count: int = Field(strict=True, ge=0, description='待到仓核对单数')
|
||||
pending_pallet_count: int = Field(strict=True, ge=0, description='待到仓核对托盘数(即退回中托盘)')
|
||||
pending_filled_count: int = Field(strict=True, ge=0, description='待核对托盘中仍有余量的托盘数')
|
||||
pending_package_sum: int = Field(strict=True, ge=0, description='待核对托盘剩余包数之和')
|
||||
oldest_pending_at: datetime | None = Field(default=None, description='最早一张待核对单的申请时间')
|
||||
month_start: date = Field(description='本月统计起始日期')
|
||||
month_confirmed_count: int = Field(strict=True, ge=0, description='本月已确认单数')
|
||||
month_confirmed_pallet_count: int = Field(strict=True, ge=0, description='本月已确认托盘数')
|
||||
month_filled_pallet_count: int = Field(strict=True, ge=0, description='本月确认后回到有料库存的托盘数')
|
||||
month_package_sum: int = Field(strict=True, ge=0, description='本月确认带回的剩余包数')
|
||||
month_avg_confirm_hours: float | None = Field(default=None, ge=0, description='本月申请到确认的平均小时数')
|
||||
|
||||
|
||||
class InboundPageQueryModel(BaseModel):
|
||||
"""首次入库分页查询模型。"""
|
||||
|
||||
@@ -440,6 +648,13 @@ class FillingPageQueryModel(BaseModel):
|
||||
page_num: int = Field(default=1, gt=0, description='当前页码')
|
||||
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
|
||||
filling_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='装填单号')
|
||||
keyword: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'\S',
|
||||
description='关键字:匹配装填单号、托盘编号或生产批次',
|
||||
)
|
||||
warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID')
|
||||
strain_id: int | None = Field(default=None, gt=0, description='菌种ID')
|
||||
batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='生产批次')
|
||||
@@ -450,6 +665,36 @@ class FillingPageQueryModel(BaseModel):
|
||||
_validate_date_range = model_validator(mode='after')(_validate_page_query_date_range)
|
||||
|
||||
|
||||
class FillingBatchPageQueryModel(BaseModel):
|
||||
"""生产批次汇总分页查询模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
page_num: int = Field(default=1, gt=0, description='当前页码')
|
||||
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
|
||||
keyword: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'\S',
|
||||
description='关键字:匹配生产批次、菌种编码或名称',
|
||||
)
|
||||
strain_id: int | None = Field(default=None, gt=0, description='菌种ID')
|
||||
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='装填开始日期')
|
||||
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='装填结束日期')
|
||||
|
||||
_validate_date_range = model_validator(mode='after')(_validate_page_query_date_range)
|
||||
|
||||
|
||||
class FillingBatchDetailQueryModel(BaseModel):
|
||||
"""生产批次详情查询模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
strain_id: int = Field(gt=0, description='菌种ID')
|
||||
batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次')
|
||||
|
||||
|
||||
class ShipmentPageQueryModel(BaseModel):
|
||||
"""整托发货分页查询模型。"""
|
||||
|
||||
@@ -458,9 +703,17 @@ class ShipmentPageQueryModel(BaseModel):
|
||||
page_num: int = Field(default=1, gt=0, description='当前页码')
|
||||
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
|
||||
shipment_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='发货单号')
|
||||
keyword: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'\S',
|
||||
description='关键字:匹配发货单号、来源仓库、目标现场或客户名称',
|
||||
)
|
||||
source_warehouse_id: int | None = Field(default=None, gt=0, description='来源仓库ID')
|
||||
target_site_id: int | None = Field(default=None, gt=0, description='目标现场ID')
|
||||
status: DocumentStatus | None = Field(default=None, description='单据状态')
|
||||
receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = Field(default=None, description='收货核对状态')
|
||||
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间')
|
||||
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间')
|
||||
|
||||
@@ -475,6 +728,14 @@ class UsagePageQueryModel(BaseModel):
|
||||
page_num: int = Field(default=1, gt=0, description='当前页码')
|
||||
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
|
||||
usage_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='使用记录号')
|
||||
keyword: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'\S',
|
||||
description='关键字:匹配使用记录号或托盘编号',
|
||||
)
|
||||
fifo_skipped: bool | None = Field(default=None, description='为 true 时只看跳过先到托盘的记录')
|
||||
site_id: int | None = Field(default=None, gt=0, description='现场ID')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
|
||||
trace_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='物料溯源号')
|
||||
@@ -494,6 +755,13 @@ class ReturnPageQueryModel(BaseModel):
|
||||
page_num: int = Field(default=1, gt=0, description='当前页码')
|
||||
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
|
||||
return_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回单号')
|
||||
keyword: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'\S',
|
||||
description='关键字:匹配退回单号、托盘编号、来源现场、客户或目标仓库名称',
|
||||
)
|
||||
source_site_id: int | None = Field(default=None, gt=0, description='来源现场ID')
|
||||
target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID')
|
||||
status: ReturnStatus | None = Field(default=None, description='退回状态')
|
||||
@@ -547,6 +815,71 @@ class BusinessOptionModel(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='业务名称')
|
||||
|
||||
|
||||
class ReturnSiteOptionModel(BusinessOptionModel):
|
||||
"""退回来源现场选项,附带所属客户名称。"""
|
||||
|
||||
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
|
||||
|
||||
|
||||
class UsageSiteOptionModel(BusinessOptionModel):
|
||||
"""现场使用页的现场选项,附带客户名称与启用隧道、产线数。"""
|
||||
|
||||
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
|
||||
tunnel_count: int = Field(default=0, strict=True, ge=0, description='启用隧道数')
|
||||
line_count: int = Field(default=0, strict=True, ge=0, description='启用产线数')
|
||||
|
||||
|
||||
class UsageBoardTunnelModel(BaseModel):
|
||||
"""隧道看板中的一条隧道(不含内部溯源号)。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
tunnel_id: int = Field(gt=0, description='隧道ID')
|
||||
tunnel_code: str = Field(min_length=1, max_length=64, description='隧道编号')
|
||||
tunnel_name: str = Field(min_length=1, max_length=100, description='隧道名称')
|
||||
in_use: bool = Field(description='是否有正在使用的物料批')
|
||||
pallet_count: int = Field(strict=True, ge=0, description='在隧道中仍有余量的托盘数')
|
||||
remaining_packages: int = Field(strict=True, ge=0, description='在隧道中托盘的剩余包数')
|
||||
started_at: datetime | None = Field(default=None, description='本轮物料批开始时间')
|
||||
last_used_at: datetime | None = Field(default=None, description='最近一次登记使用时间')
|
||||
last_line_name: str | None = Field(default=None, max_length=100, description='最近一次使用的产线名称')
|
||||
|
||||
|
||||
class UsageLinePackagesModel(BaseModel):
|
||||
"""按产线统计的用量。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
line_id: int | None = Field(default=None, gt=0, description='产线ID;未指定产线时为空')
|
||||
line_name: str | None = Field(default=None, max_length=100, description='产线名称')
|
||||
used_packages: int = Field(strict=True, ge=0, description='使用包数')
|
||||
|
||||
|
||||
class UsageSiteBoardModel(BaseModel):
|
||||
"""客户现场看板:库存、今日用量、隧道与先到先用概况(只读)。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
site_id: int = Field(gt=0, description='现场ID')
|
||||
site_code: str | None = Field(default=None, max_length=64, description='现场编码')
|
||||
site_name: str | None = Field(default=None, max_length=100, description='现场名称')
|
||||
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
|
||||
pallet_count: int = Field(strict=True, ge=0, description='现场未使用与使用中的托盘数')
|
||||
package_sum: int = Field(strict=True, ge=0, description='上述托盘的剩余包数')
|
||||
unused_pallet_count: int = Field(strict=True, ge=0, description='现场未使用托盘数')
|
||||
partial_pallet_count: int = Field(strict=True, ge=0, description='现场使用中托盘数')
|
||||
empty_pallet_count: int = Field(strict=True, ge=0, description='现场已用完、待退回的托盘数')
|
||||
today_usage_count: int = Field(strict=True, ge=0, description='今日登记次数')
|
||||
today_used_packages: int = Field(strict=True, ge=0, description='今日使用包数')
|
||||
today_by_line: list[UsageLinePackagesModel] = Field(default_factory=list, description='今日用量按产线,从多到少')
|
||||
tunnel_count: int = Field(strict=True, ge=0, description='启用隧道数')
|
||||
active_tunnel_count: int = Field(strict=True, ge=0, description='正在使用的隧道数')
|
||||
today_fifo_skip_count: int = Field(strict=True, ge=0, description='今日跳过先到托盘的使用次数')
|
||||
week_fifo_skip_count: int = Field(strict=True, ge=0, description='近 7 天跳过先到托盘的使用次数')
|
||||
tunnels: list[UsageBoardTunnelModel] = Field(default_factory=list, description='启用隧道,按编号排序')
|
||||
lines: list[BusinessOptionModel] = Field(default_factory=list, description='启用产线,供筛选使用')
|
||||
|
||||
|
||||
class PalletScanResponseModel(BaseModel):
|
||||
"""业务扫码托盘响应模型。"""
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
@@ -21,3 +21,34 @@ class UsageFifoRecommendationModel(BaseModel):
|
||||
unknown_arrival_count: int = 0
|
||||
requires_skip_reason: bool = False
|
||||
message: str
|
||||
|
||||
|
||||
class UsageFifoQueueItemModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
pallet_id: int
|
||||
pallet_code: str
|
||||
batch_no: str
|
||||
remaining_packages: int
|
||||
site_arrived_at: datetime
|
||||
expiry_date: date | None = None
|
||||
|
||||
|
||||
class UsageFifoStrainQueueModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
strain_id: int
|
||||
strain_code: str | None = None
|
||||
strain_name: str | None = None
|
||||
pallet_count: int = 0
|
||||
unknown_arrival_count: int = 0
|
||||
pallets: list[UsageFifoQueueItemModel] = []
|
||||
|
||||
|
||||
class UsageFifoQueueModel(BaseModel):
|
||||
"""现场待使用托盘按菌种分组,组内与先到先用建议同序。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
site_id: int
|
||||
strains: list[UsageFifoStrainQueueModel] = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.filling_summary_dao import FillingSummaryDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
@@ -22,8 +23,14 @@ from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEv
|
||||
from module_business.entity.vo.master_vo import StrainPageQueryModel
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingBatchDetailModel,
|
||||
FillingBatchDetailQueryModel,
|
||||
FillingBatchModel,
|
||||
FillingBatchPageQueryModel,
|
||||
FillingCreateModel,
|
||||
FillingListItemModel,
|
||||
FillingModel,
|
||||
FillingOverviewModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
@@ -34,6 +41,9 @@ from module_business.service.test_batch_access_service import TestBatchAccessSer
|
||||
class FillingService:
|
||||
"""仓库内单托盘即时装填服务层。"""
|
||||
|
||||
# 有效期剩余不超过该天数的在仓批次视为临期
|
||||
NEAR_EXPIRY_DAYS = 14
|
||||
|
||||
@staticmethod
|
||||
def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]:
|
||||
user = current_user.user
|
||||
@@ -187,7 +197,7 @@ class FillingService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[FillingModel]:
|
||||
) -> PageModel | list[FillingListItemModel]:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if (
|
||||
warehouse_ids is not None
|
||||
@@ -203,11 +213,81 @@ class FillingService:
|
||||
is_page=is_page,
|
||||
)
|
||||
rows = result.rows if isinstance(result, PageModel) else result
|
||||
models = [FillingModel.model_validate(record) for record in rows]
|
||||
records = [FillingModel.model_validate(record) for record in rows]
|
||||
# 仅对本页已按数据范围筛出的记录补充托盘编号、仓库与操作人名称
|
||||
summaries = await FillingSummaryDao.get_list_summaries(
|
||||
query_db,
|
||||
[record.filling_id for record in records],
|
||||
test_scope,
|
||||
)
|
||||
models = [
|
||||
FillingListItemModel.model_validate({**record.model_dump(), **summaries.get(record.filling_id, {})})
|
||||
for record in records
|
||||
]
|
||||
if isinstance(result, PageModel):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
|
||||
@classmethod
|
||||
async def get_filling_batches_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
query_object: FillingBatchPageQueryModel,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> PageModel:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
batches, total = await FillingSummaryDao.get_batch_page(query_db, query_object, warehouse_ids, test_scope)
|
||||
return PageModel(
|
||||
rows=[FillingBatchModel.model_validate(batch) for batch in batches],
|
||||
pageNum=query_object.page_num,
|
||||
pageSize=query_object.page_size,
|
||||
total=total,
|
||||
hasNext=query_object.page_num * query_object.page_size < total,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_filling_batch_detail_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
query_object: FillingBatchDetailQueryModel,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> FillingBatchDetailModel:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
batch = await FillingSummaryDao.get_batch_detail(
|
||||
query_db,
|
||||
query_object.strain_id,
|
||||
query_object.batch_no,
|
||||
warehouse_ids,
|
||||
test_scope,
|
||||
)
|
||||
if batch is None:
|
||||
if cls._is_admin(current_user):
|
||||
raise ServiceException(message='生产批次不存在')
|
||||
raise PermissionException(data='', message='无权访问该生产批次')
|
||||
return FillingBatchDetailModel.model_validate(batch)
|
||||
|
||||
@classmethod
|
||||
async def get_filling_overview_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> FillingOverviewModel:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
overview = await FillingSummaryDao.get_overview(
|
||||
query_db,
|
||||
warehouse_ids,
|
||||
test_scope,
|
||||
today_start,
|
||||
today_start.replace(day=1),
|
||||
(today_start + timedelta(days=cls.NEAR_EXPIRY_DAYS)).date(),
|
||||
cls.NEAR_EXPIRY_DAYS,
|
||||
)
|
||||
return FillingOverviewModel.model_validate(overview)
|
||||
|
||||
@classmethod
|
||||
async def filling_detail_services(
|
||||
cls,
|
||||
|
||||
@@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel
|
||||
from exceptions.exception import PermissionException, ServiceException, ServiceWarning
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inbound_summary_dao import InboundSummaryDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
@@ -29,7 +30,10 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
InboundCreateModel,
|
||||
InboundItemModel,
|
||||
InboundListItemModel,
|
||||
InboundModel,
|
||||
InboundOverviewModel,
|
||||
InboundPageQueryModel,
|
||||
InboundScanQueryModel,
|
||||
InboundUpdateModel,
|
||||
@@ -245,7 +249,7 @@ class InboundService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[InboundModel]:
|
||||
) -> PageModel | list[InboundListItemModel]:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if (
|
||||
warehouse_ids is not None
|
||||
@@ -262,7 +266,17 @@ class InboundService:
|
||||
is_page=is_page,
|
||||
)
|
||||
rows = result.rows if isinstance(result, PageModel) else result
|
||||
models = [InboundModel.model_validate(record) for record in rows]
|
||||
records = [InboundModel.model_validate(record) for record in rows]
|
||||
# 仅对本页已按数据范围筛出的单据补充名称与托盘数
|
||||
summaries = await InboundSummaryDao.get_list_summaries(
|
||||
query_db,
|
||||
[record.inbound_id for record in records],
|
||||
test_scope,
|
||||
)
|
||||
models = [
|
||||
InboundListItemModel.model_validate({**record.model_dump(), **summaries.get(record.inbound_id, {})})
|
||||
for record in records
|
||||
]
|
||||
if isinstance(result, PageModel):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
@@ -284,7 +298,31 @@ class InboundService:
|
||||
)
|
||||
if inbound is None:
|
||||
cls._raise_inbound_not_accessible(current_user)
|
||||
return InboundModel.model_validate(inbound)
|
||||
model = InboundModel.model_validate(inbound)
|
||||
if not model.items:
|
||||
return model
|
||||
# 托盘编号与当前状态只读展示,不参与任何写入或校验
|
||||
identities = await InboundSummaryDao.get_item_identities(query_db, model.inbound_id, test_scope)
|
||||
return model.model_copy(
|
||||
update={
|
||||
'items': [
|
||||
InboundItemModel.model_validate({**item.model_dump(), **identities.get(item.inbound_item_id, {})})
|
||||
for item in model.items
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_inbound_overview_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> InboundOverviewModel:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
overview = await InboundSummaryDao.get_overview(query_db, warehouse_ids, test_scope, month_start)
|
||||
return InboundOverviewModel.model_validate(overview)
|
||||
|
||||
@classmethod
|
||||
async def get_warehouse_options_services(
|
||||
|
||||
@@ -15,6 +15,7 @@ from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import MasterImportPreviewModel, MasterImportPreviewRowModel
|
||||
from module_business.entity.vo.master_vo import (
|
||||
CustomerModel,
|
||||
LineModel,
|
||||
@@ -33,6 +34,11 @@ _MAX_IMPORT_BYTES = 5 * 1024 * 1024
|
||||
_MAX_IMPORT_ROWS = 5000
|
||||
_STATUS_TO_VALUE = {'正常': '0', '停用': '1'}
|
||||
_VALUE_TO_STATUS = {'0': '正常', '1': '停用'}
|
||||
_PREVIEW_MESSAGES: dict[ImportOutcome, str] = {
|
||||
'add': '校验通过,将新增',
|
||||
'update': '{code}已存在,将按文件内容更新',
|
||||
'skip': '{code}已存在,保持原资料不变',
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -245,6 +251,65 @@ class MasterExcelService:
|
||||
raise ServiceException(message='导入失败,数据已全部回滚') from exc
|
||||
return CrudResponseModel(is_success=True, message=cls._summary(added, updated, skipped, details))
|
||||
|
||||
@classmethod
|
||||
async def preview_import(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
resource: MasterExcelResource,
|
||||
file: UploadFile,
|
||||
update_support: bool,
|
||||
current_user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
) -> MasterImportPreviewModel:
|
||||
"""走与正式导入完全相同的逐行校验,最后整体回滚,返回每行将发生的结果。"""
|
||||
spec = _SPECS[resource]
|
||||
parsed_rows = await cls._read_upload(resource, file)
|
||||
rows: list[MasterImportPreviewRowModel] = []
|
||||
try:
|
||||
if resource == 'pallet':
|
||||
await PalletService._prepare_test_write(db, current_user, scope)
|
||||
else:
|
||||
await MasterService._prepare_test_write(db, current_user, scope)
|
||||
for row in parsed_rows:
|
||||
values = [row.values.get(header, '') for header in spec.headers]
|
||||
try:
|
||||
outcome, _ = await cls._apply_row(db, resource, row, update_support, current_user, scope)
|
||||
except ServiceException as exc:
|
||||
rows.append(
|
||||
MasterImportPreviewRowModel(
|
||||
excel_row=row.excel_row,
|
||||
outcome='error',
|
||||
message=exc.message or '校验失败',
|
||||
values=values,
|
||||
)
|
||||
)
|
||||
continue
|
||||
rows.append(
|
||||
MasterImportPreviewRowModel(
|
||||
excel_row=row.excel_row,
|
||||
outcome=outcome,
|
||||
message=_PREVIEW_MESSAGES[outcome].format(code=spec.code_header),
|
||||
values=values,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, (ServiceException, PermissionException)):
|
||||
raise
|
||||
raise ServiceException(message='导入校验失败,请稍后重试') from exc
|
||||
finally:
|
||||
await db.rollback()
|
||||
counts = {outcome: sum(1 for row in rows if row.outcome == outcome) for outcome in _PREVIEW_MESSAGES}
|
||||
return MasterImportPreviewModel(
|
||||
headers=list(spec.headers),
|
||||
update_support=update_support,
|
||||
total_rows=len(rows),
|
||||
add_count=counts['add'],
|
||||
update_count=counts['update'],
|
||||
skip_count=counts['skip'],
|
||||
error_count=sum(1 for row in rows if row.outcome == 'error'),
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _read_upload(cls, resource: MasterExcelResource, file: UploadFile) -> list[ParsedRow]:
|
||||
spec = _SPECS[resource]
|
||||
|
||||
@@ -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.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.return_summary_dao import ReturnSummaryDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState, ReturnStatus
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
@@ -32,10 +33,14 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
ReturnConfirmModel,
|
||||
ReturnItemModel,
|
||||
ReturnListItemModel,
|
||||
ReturnModel,
|
||||
ReturnOverviewModel,
|
||||
ReturnPageQueryModel,
|
||||
ReturnRequestModel,
|
||||
ReturnScanQueryModel,
|
||||
ReturnSiteOptionModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
@@ -211,7 +216,7 @@ class ReturnService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[ReturnModel]:
|
||||
) -> PageModel | list[ReturnListItemModel]:
|
||||
site_ids, warehouse_ids = await cls._scopes(query_db, current_user)
|
||||
if site_ids == [] and warehouse_ids == []:
|
||||
if is_page:
|
||||
@@ -233,7 +238,17 @@ class ReturnService:
|
||||
is_page=is_page,
|
||||
)
|
||||
rows = result.rows if isinstance(result, PageModel) else result
|
||||
models = [ReturnModel.model_validate(record) for record in rows]
|
||||
records = [ReturnModel.model_validate(record) for record in rows]
|
||||
# 仅对本页已按数据范围筛出的单据补充名称与托盘汇总
|
||||
summaries = await ReturnSummaryDao.get_list_summaries(
|
||||
query_db,
|
||||
[record.return_id for record in records],
|
||||
test_scope,
|
||||
)
|
||||
models = [
|
||||
ReturnListItemModel.model_validate({**record.model_dump(), **summaries.get(record.return_id, {})})
|
||||
for record in records
|
||||
]
|
||||
if isinstance(result, PageModel):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
@@ -257,7 +272,42 @@ class ReturnService:
|
||||
if return_order is None:
|
||||
cls._raise_return_not_accessible(current_user)
|
||||
assert_record_in_scope(return_order, test_scope, '退回单')
|
||||
return ReturnModel.model_validate(return_order)
|
||||
model = ReturnModel.model_validate(return_order)
|
||||
if not model.items:
|
||||
return model
|
||||
# 托盘编号与隧道名称只读展示,不参与确认核对;核对仍只使用托盘ID
|
||||
identities = await ReturnSummaryDao.get_item_identities(query_db, model.return_id, test_scope)
|
||||
return model.model_copy(
|
||||
update={
|
||||
'items': [
|
||||
ReturnItemModel.model_validate({**item.model_dump(), **identities.get(item.return_item_id, {})})
|
||||
for item in model.items
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_return_overview_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> ReturnOverviewModel:
|
||||
site_ids, warehouse_ids = await cls._scopes(query_db, current_user)
|
||||
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
overview = await ReturnSummaryDao.get_overview(query_db, site_ids, warehouse_ids, test_scope, month_start)
|
||||
return ReturnOverviewModel.model_validate(overview)
|
||||
|
||||
@classmethod
|
||||
async def get_site_options_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> list[ReturnSiteOptionModel]:
|
||||
site_ids, warehouse_ids = await cls._scopes(query_db, current_user)
|
||||
options = await ReturnSummaryDao.get_site_options(query_db, site_ids, warehouse_ids, test_scope)
|
||||
return [ReturnSiteOptionModel.model_validate(option) for option in options]
|
||||
|
||||
@classmethod
|
||||
async def get_warehouse_options_services(
|
||||
|
||||
@@ -14,6 +14,8 @@ from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.shipment_label_dao import ShipmentLabelDao
|
||||
from module_business.dao.shipment_summary_dao import ShipmentSummaryDao
|
||||
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
@@ -31,6 +33,7 @@ from module_business.entity.vo.operation_vo import (
|
||||
ConfirmCommandModel,
|
||||
PalletScanResponseModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentListItemModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
@@ -254,7 +257,7 @@ class ShipmentService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[ShipmentModel]:
|
||||
) -> PageModel | list[ShipmentListItemModel]:
|
||||
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
|
||||
if (
|
||||
warehouse_ids is not None
|
||||
@@ -288,7 +291,17 @@ class ShipmentService:
|
||||
is_page=is_page,
|
||||
)
|
||||
rows = result.rows if isinstance(result, PageModel) else result
|
||||
models = [ShipmentModel.model_validate(record) for record in rows]
|
||||
records = [ShipmentModel.model_validate(record) for record in rows]
|
||||
# 仅对本页已按数据范围筛出的单据补充名称与汇总
|
||||
summaries = await ShipmentSummaryDao.get_list_summaries(
|
||||
query_db,
|
||||
[record.shipment_id for record in records],
|
||||
test_scope,
|
||||
)
|
||||
models = [
|
||||
ShipmentListItemModel.model_validate({**record.model_dump(), **summaries.get(record.shipment_id, {})})
|
||||
for record in records
|
||||
]
|
||||
if isinstance(result, PageModel):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
@@ -311,7 +324,35 @@ class ShipmentService:
|
||||
)
|
||||
if shipment is None:
|
||||
cls._raise_shipment_not_accessible(current_user)
|
||||
return ShipmentModel.model_validate(shipment)
|
||||
model = ShipmentModel.model_validate(shipment)
|
||||
identities = await cls._item_identities(query_db, shipment, test_scope)
|
||||
if not identities:
|
||||
return model
|
||||
return model.model_copy(
|
||||
update={
|
||||
'items': [
|
||||
item.model_copy(update=identities.get(item.shipment_item_id, {}))
|
||||
for item in model.items
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _item_identities(
|
||||
query_db: AsyncSession,
|
||||
shipment: BizShipmentOrder,
|
||||
test_scope: TestBatchScope,
|
||||
) -> dict[int, dict[str, str | None]]:
|
||||
"""为详情明细补充托盘编号与菌种批次,只读展示,不参与任何写入或数量计算。"""
|
||||
identities: dict[int, dict[str, str | None]] = {}
|
||||
for item, pallet, filling, _quantity in await ShipmentLabelDao.get_label_sources(query_db, shipment, test_scope):
|
||||
identities[item.shipment_item_id] = {
|
||||
'pallet_code': getattr(pallet, 'pallet_code', None),
|
||||
'strain_code': getattr(filling, 'strain_code_snapshot', None),
|
||||
'strain_name': getattr(filling, 'strain_name_snapshot', None),
|
||||
'batch_no': getattr(filling, 'batch_no', None),
|
||||
}
|
||||
return identities
|
||||
|
||||
@classmethod
|
||||
async def get_warehouse_options_services(
|
||||
|
||||
@@ -6,7 +6,8 @@ from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.usage_fifo_dao import UsageFifoDao
|
||||
from module_business.domain.fifo_policy import requires_fifo_skip_reason
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoQueueModel, UsageFifoRecommendationModel
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.usage_service import UsageService
|
||||
|
||||
|
||||
@@ -52,3 +53,19 @@ class UsageFifoService:
|
||||
recommended=candidate, unknown_arrival_count=unknown,
|
||||
requires_skip_reason=needs_reason, message=message,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def queue(
|
||||
db: AsyncSession,
|
||||
site_id: int,
|
||||
user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
) -> UsageFifoQueueModel:
|
||||
"""现场待使用队列(只读):与建议共用候选条件和排序,仅包含尚未入隧的托盘。"""
|
||||
await AccessService.assert_site_access(db, user, site_id)
|
||||
site = await MasterDao.get_site(db, site_id, scope)
|
||||
if site is None:
|
||||
raise ServiceException(message='客户现场不存在')
|
||||
assert_record_in_scope(site, scope, '现场')
|
||||
strains = await UsageFifoDao.queue(db, site_id=site_id, scope=scope)
|
||||
return UsageFifoQueueModel.model_validate({'site_id': site_id, 'strains': strains})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -17,6 +17,7 @@ from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.usage_fifo_dao import UsageFifoDao
|
||||
from module_business.dao.usage_summary_dao import UsageSummaryDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState
|
||||
from module_business.domain.fifo_policy import requires_fifo_skip_reason
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
@@ -29,8 +30,11 @@ from module_business.entity.vo.operation_vo import (
|
||||
TunnelEntryCreateModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageListItemModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
UsageSiteBoardModel,
|
||||
UsageSiteOptionModel,
|
||||
UsageSuccessResponseModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
@@ -279,7 +283,7 @@ class UsageService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[UsageModel]:
|
||||
) -> PageModel | list[UsageListItemModel]:
|
||||
site_ids = await cls._site_scope(query_db, current_user)
|
||||
if site_ids is not None and query_object.site_id is not None and query_object.site_id not in site_ids:
|
||||
raise PermissionException(data='', message='无权访问该客户现场')
|
||||
@@ -301,11 +305,56 @@ class UsageService:
|
||||
is_page=is_page,
|
||||
)
|
||||
rows = result.rows if isinstance(result, PageModel) else result
|
||||
models = [UsageModel.model_validate(record) for record in rows]
|
||||
records = [UsageModel.model_validate(record) for record in rows]
|
||||
# 仅对本页已按数据范围筛出的记录补充名称;不改变记录本身
|
||||
summaries = await UsageSummaryDao.get_list_summaries(
|
||||
query_db,
|
||||
[record.usage_id for record in records],
|
||||
test_scope,
|
||||
)
|
||||
models = [
|
||||
UsageListItemModel.model_validate({**record.model_dump(), **summaries.get(record.usage_id, {})})
|
||||
for record in records
|
||||
]
|
||||
if isinstance(result, PageModel):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
|
||||
@classmethod
|
||||
async def get_site_options_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> list[UsageSiteOptionModel]:
|
||||
site_ids = await cls._site_scope(query_db, current_user)
|
||||
options = await UsageSummaryDao.get_site_options(query_db, site_ids, test_scope)
|
||||
return [UsageSiteOptionModel.model_validate(option) for option in options]
|
||||
|
||||
@classmethod
|
||||
async def get_site_board_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
site_id: int,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> UsageSiteBoardModel:
|
||||
cls._current_identity(current_user)
|
||||
await AccessService.assert_site_access(query_db, current_user, site_id)
|
||||
site = await MasterDao.get_site(query_db, site_id, test_scope)
|
||||
if site is None:
|
||||
raise ServiceException(message='客户现场不存在')
|
||||
assert_record_in_scope(site, test_scope, '现场')
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
board = await UsageSummaryDao.get_site_board(
|
||||
query_db,
|
||||
site,
|
||||
test_scope,
|
||||
today_start,
|
||||
today_start - timedelta(days=6),
|
||||
)
|
||||
return UsageSiteBoardModel.model_validate(board)
|
||||
|
||||
@classmethod
|
||||
async def usage_detail_services(
|
||||
cls,
|
||||
|
||||
Reference in New Issue
Block a user