更新登录页及优化相关功能
This commit is contained in:
@@ -72,7 +72,7 @@ async def get_business_filling_strain_options(
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描待装填托盘接口',
|
||||
description='按托盘当前仓库数据范围判断空托盘是否可装填',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
|
||||
@@ -75,7 +75,7 @@ async def get_business_inbound_warehouse_options(
|
||||
|
||||
|
||||
@inbound_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描首次入库托盘接口',
|
||||
description='查询托盘是否可加入所选仓库的首次入库单',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
|
||||
@@ -90,7 +90,7 @@ async def get_shipment_report_list(
|
||||
|
||||
@report_controller.get(
|
||||
'/consumption/list',
|
||||
summary='获取产线消耗报表分页列表接口',
|
||||
summary='获取隧道消耗报表分页列表接口(兼容产线)',
|
||||
response_model=PageResponseModel[ConsumptionReportModel],
|
||||
dependencies=[UserInterfaceAuthDependency('report:consumption:list')],
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ async def get_business_return_warehouse_options(
|
||||
|
||||
|
||||
@return_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描退回托盘接口',
|
||||
description='按申请现场或确认仓库数据范围重新查询托盘退回状态',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
|
||||
@@ -23,6 +23,8 @@ from module_business.entity.vo.operation_vo import (
|
||||
ShipmentScanQueryModel,
|
||||
ShipmentUpdateModel,
|
||||
)
|
||||
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
|
||||
from module_business.service.shipment_label_service import ShipmentLabelService
|
||||
from module_business.service.shipment_service import ShipmentService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
@@ -92,7 +94,7 @@ async def get_business_shipment_site_options(
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描整托发货托盘接口',
|
||||
description='查询托盘是否可加入指定仓库至现场的整托发货单',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
@@ -126,6 +128,24 @@ async def scan_business_shipment_pallet(
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/{shipment_id}/labels',
|
||||
summary='获取整托发货标签接口',
|
||||
description='在发货查询权限与仓库、现场、数据批次范围内读取原托盘标签',
|
||||
response_model=DataResponseModel[list[ShipmentLabelModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
|
||||
)
|
||||
async def get_business_shipment_labels(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0, description='发货单ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentLabelService.get_labels_services(query_db, shipment_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result, headers={'Cache-Control': 'no-store'})
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/{shipment_id}',
|
||||
summary='获取整托发货单详情接口',
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path, Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.enums import BusinessType
|
||||
from common.router import APIRouterPro
|
||||
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.operation_vo import ShipmentModel
|
||||
from module_business.entity.vo.shipment_receipt_vo import (
|
||||
ShipmentReceiptCommandModel,
|
||||
ShipmentReceiptItemModel,
|
||||
ShipmentReceiptModel,
|
||||
ShipmentReceiptQueryModel,
|
||||
)
|
||||
from module_business.service.shipment_receipt_service import ShipmentReceiptService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
shipment_receipt_controller = APIRouterPro(
|
||||
prefix='/business/shipment-receipt',
|
||||
tags=['业务管理-发货收货核对'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@shipment_receipt_controller.get(
|
||||
'/list',
|
||||
response_model=PageResponseModel[ShipmentModel],
|
||||
summary='查询目标现场收货单',
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
|
||||
)
|
||||
async def list_shipment_receipts(
|
||||
request: Request,
|
||||
query: Annotated[ShipmentReceiptQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentReceiptService.list_services(query_db, query, current_user, test_scope)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@shipment_receipt_controller.get(
|
||||
'/{shipment_id}',
|
||||
response_model=DataResponseModel[ShipmentReceiptModel],
|
||||
summary='查询原发货核对明细',
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
|
||||
)
|
||||
async def get_shipment_receipt(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0)],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentReceiptService.detail_services(query_db, shipment_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_receipt_controller.get(
|
||||
'/{shipment_id}/scan/{identifier:path}',
|
||||
response_model=DataResponseModel[ShipmentReceiptItemModel],
|
||||
summary='扫码或录入原托盘号核对',
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
|
||||
)
|
||||
@Log(
|
||||
title='收货扫码',
|
||||
business_type=BusinessType.OTHER,
|
||||
request_log_mode='none',
|
||||
response_log_mode='summary',
|
||||
oper_url_mode='route',
|
||||
)
|
||||
async def scan_shipment_receipt(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0)],
|
||||
identifier: Annotated[str, Path(min_length=1, max_length=128, pattern=r'\S')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentReceiptService.scan_services(query_db, shipment_id, identifier, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_receipt_controller.put(
|
||||
'/{shipment_id}/confirm',
|
||||
response_model=ResponseBaseModel,
|
||||
summary='接收方确认核对收货',
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
|
||||
)
|
||||
@Log(title='发货收货核对', business_type=BusinessType.UPDATE)
|
||||
async def confirm_shipment_receipt(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0)],
|
||||
command: ShipmentReceiptCommandModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
result = await ShipmentReceiptService.confirm_services(query_db, shipment_id, command, current_user, test_scope)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
@@ -0,0 +1,126 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path, Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.enums import BusinessType
|
||||
from common.router import APIRouterPro
|
||||
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_vo import DeleteTunnelModel, TunnelModel, TunnelPageQueryModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
tunnel_controller = APIRouterPro(
|
||||
prefix='/master/tunnel',
|
||||
tags=['基础资料-隧道管理'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@tunnel_controller.get(
|
||||
'/list',
|
||||
summary='获取隧道分页列表接口',
|
||||
description='用于获取客户现场隧道分页列表',
|
||||
response_model=PageResponseModel[TunnelModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:list')],
|
||||
)
|
||||
async def get_master_tunnel_list(
|
||||
request: Request,
|
||||
tunnel_page_query: Annotated[TunnelPageQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await MasterService.list_tunnel(query_db, tunnel_page_query, scope, is_page=True)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@tunnel_controller.get(
|
||||
'/{tunnel_id}',
|
||||
summary='获取隧道详情接口',
|
||||
description='用于获取指定隧道的详细信息',
|
||||
response_model=DataResponseModel[TunnelModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:query')],
|
||||
)
|
||||
async def query_detail_master_tunnel(
|
||||
request: Request,
|
||||
tunnel_id: Annotated[int, Path(gt=0, description='隧道ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await MasterService.detail_tunnel(query_db, tunnel_id, scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@tunnel_controller.post(
|
||||
'',
|
||||
summary='新增隧道接口',
|
||||
description='用于新增客户现场隧道',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:add')],
|
||||
)
|
||||
@Log(title='隧道管理', business_type=BusinessType.INSERT)
|
||||
async def add_master_tunnel(
|
||||
request: Request,
|
||||
add_tunnel: TunnelModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
current_time = datetime.now()
|
||||
add_tunnel.create_by = current_user.user.user_name
|
||||
add_tunnel.create_time = current_time
|
||||
add_tunnel.update_by = current_user.user.user_name
|
||||
add_tunnel.update_time = current_time
|
||||
result = await MasterService.add_tunnel(query_db, add_tunnel, current_user, scope)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@tunnel_controller.put(
|
||||
'',
|
||||
summary='编辑隧道接口',
|
||||
description='用于编辑客户现场隧道',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:edit')],
|
||||
)
|
||||
@Log(title='隧道管理', business_type=BusinessType.UPDATE)
|
||||
async def edit_master_tunnel(
|
||||
request: Request,
|
||||
edit_tunnel: TunnelModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
edit_tunnel.update_by = current_user.user.user_name
|
||||
edit_tunnel.update_time = datetime.now()
|
||||
result = await MasterService.edit_tunnel(query_db, edit_tunnel, current_user, scope)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@tunnel_controller.delete(
|
||||
'/{tunnel_ids}',
|
||||
summary='兼容旧接口,固定拒绝,请使用测试数据批次清理',
|
||||
description='兼容旧接口,固定拒绝单条物理删除,请使用测试数据批次清理',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:remove')],
|
||||
)
|
||||
@Log(title='隧道管理', business_type=BusinessType.DELETE)
|
||||
async def delete_master_tunnel(
|
||||
request: Request,
|
||||
tunnel_ids: Annotated[
|
||||
str,
|
||||
Path(pattern=r'^[1-9]\d*(,[1-9]\d*)*$', description='需要删除的隧道ID'),
|
||||
],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
delete_tunnel = DeleteTunnelModel(tunnelIds=[int(tunnel_id) for tunnel_id in tunnel_ids.split(',')])
|
||||
result = await MasterService.remove_tunnel(query_db, delete_tunnel)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
@@ -18,6 +18,8 @@ from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
TunnelEntryCreateModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
@@ -76,7 +78,44 @@ async def get_business_usage_line_options(
|
||||
|
||||
|
||||
@usage_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
'/tunnel-options',
|
||||
summary='获取客户现场启用隧道选项接口',
|
||||
description='仅返回当前用户有权访问的指定客户现场启用隧道',
|
||||
response_model=DataResponseModel[list[BusinessOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
|
||||
)
|
||||
async def get_business_usage_tunnel_options(
|
||||
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_tunnel_options_services(query_db, site_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@usage_controller.post(
|
||||
'/enter-tunnel',
|
||||
summary='扫码入隧接口',
|
||||
description='将单个已到客户现场的托盘绑定到指定隧道,并生成或复用隧道内部溯源号',
|
||||
response_model=DataResponseModel[TunnelEntrySuccessResponseModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:usage:add')],
|
||||
)
|
||||
@Log(title='扫码入隧', business_type=BusinessType.INSERT)
|
||||
async def enter_business_usage_tunnel(
|
||||
request: Request,
|
||||
entry_command: TunnelEntryCreateModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
result = await UsageService.enter_tunnel_services(query_db, entry_command, current_user, test_scope)
|
||||
return ResponseUtil.success(msg=result.message, data=result.result)
|
||||
|
||||
|
||||
@usage_controller.get(
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描客户现场待使用托盘接口',
|
||||
description='按托盘当前客户现场数据范围判断是否允许记录使用',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""只读先进先出建议;不强行阻断会议要求的多批次混用。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import 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.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')],
|
||||
)
|
||||
|
||||
|
||||
@usage_fifo_controller.get('/recommendation', response_model=DataResponseModel[UsageFifoRecommendationModel])
|
||||
async def get_usage_fifo_recommendation(
|
||||
request: Request,
|
||||
qr_token: Annotated[str, Query(alias='qrToken', min_length=1, max_length=128, pattern=r'\S')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
tunnel_id: Annotated[int | None, Query(alias='tunnelId', gt=0)] = None,
|
||||
) -> Response:
|
||||
result = await UsageFifoService.recommendation(
|
||||
query_db, qr_token, current_user, test_scope, tunnel_id=tunnel_id,
|
||||
)
|
||||
return ResponseUtil.success(data=result)
|
||||
@@ -1,6 +1,8 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import MultipleResultsFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from exceptions.exception import ServiceException
|
||||
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 BizPalletEvent, BizPalletInventory
|
||||
@@ -57,13 +59,18 @@ class InventoryDao:
|
||||
qr_token: str,
|
||||
scope: TestBatchScope,
|
||||
) -> BizPallet | None:
|
||||
"""兼容印刷托盘号;调用方仍须复核仓库/现场权限,写入使用规范二维码。"""
|
||||
identifier = qr_token.strip()
|
||||
result = await db.execute(
|
||||
select(BizPallet).where(
|
||||
BizPallet.qr_token == qr_token,
|
||||
or_(BizPallet.qr_token == identifier, BizPallet.pallet_code == identifier),
|
||||
batch_scope_condition(BizPallet.test_batch_id, scope),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
try:
|
||||
return result.scalar_one_or_none()
|
||||
except MultipleResultsFound:
|
||||
raise ServiceException(message='托盘号与二维码标识存在歧义,请联系管理员核对后重试') from None
|
||||
|
||||
@classmethod
|
||||
async def get_pallets_by_ids(
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import ColumnElement, Select, select, update
|
||||
from sqlalchemy import ColumnElement, Select, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
@@ -15,6 +15,7 @@ from module_business.entity.do.master_do import (
|
||||
BizPallet,
|
||||
BizProductionLine,
|
||||
BizStrain,
|
||||
BizTunnel,
|
||||
BizUserSite,
|
||||
BizUserWarehouse,
|
||||
BizWarehouse,
|
||||
@@ -31,6 +32,8 @@ from module_business.entity.vo.master_vo import (
|
||||
SitePageQueryModel,
|
||||
StrainModel,
|
||||
StrainPageQueryModel,
|
||||
TunnelModel,
|
||||
TunnelPageQueryModel,
|
||||
WarehouseModel,
|
||||
WarehousePageQueryModel,
|
||||
)
|
||||
@@ -38,7 +41,7 @@ from utils.page_util import PageUtil
|
||||
|
||||
|
||||
class MasterDao:
|
||||
"""菌种、客户、现场、产线和仓库主数据数据库操作层。"""
|
||||
"""菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。"""
|
||||
|
||||
@staticmethod
|
||||
async def _paginate(
|
||||
@@ -339,6 +342,67 @@ class MasterDao:
|
||||
async def update_line(cls, db: AsyncSession, line: LineModel, scope: TestBatchScope) -> None:
|
||||
await cls._update(db, BizProductionLine, BizProductionLine.line_id, 'line_id', line, scope)
|
||||
|
||||
@classmethod
|
||||
async def list_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
query_object: TunnelPageQueryModel,
|
||||
scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[dict[str, Any]]:
|
||||
conditions = [batch_scope_condition(BizTunnel.test_batch_id, scope)]
|
||||
if query_object.site_id:
|
||||
conditions.append(BizTunnel.site_id == query_object.site_id)
|
||||
if query_object.tunnel_code:
|
||||
conditions.append(BizTunnel.tunnel_code.like(f'%{query_object.tunnel_code}%'))
|
||||
if query_object.tunnel_name:
|
||||
conditions.append(BizTunnel.tunnel_name.like(f'%{query_object.tunnel_name}%'))
|
||||
if query_object.status:
|
||||
conditions.append(BizTunnel.status == query_object.status)
|
||||
statement = select(BizTunnel).where(*conditions).order_by(BizTunnel.tunnel_id)
|
||||
return await cls._paginate(db, statement, query_object, is_page)
|
||||
|
||||
@classmethod
|
||||
async def get_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel_id: int,
|
||||
scope: TestBatchScope,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> BizTunnel | None:
|
||||
return await cls._get(
|
||||
db,
|
||||
BizTunnel,
|
||||
BizTunnel.tunnel_id,
|
||||
tunnel_id,
|
||||
batch_scope_condition(BizTunnel.test_batch_id, scope),
|
||||
for_update=for_update,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def find_tunnel_by_code(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel_code: str,
|
||||
site_id: int,
|
||||
) -> BizTunnel | None:
|
||||
return await cls._find(
|
||||
db,
|
||||
BizTunnel,
|
||||
BizTunnel.tunnel_code,
|
||||
tunnel_code,
|
||||
BizTunnel.site_id == site_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def add_tunnel(cls, db: AsyncSession, tunnel: TunnelModel, test_batch_id: int | None) -> BizTunnel:
|
||||
return await cls._add(db, BizTunnel, 'tunnel_id', tunnel, test_batch_id)
|
||||
|
||||
@classmethod
|
||||
async def update_tunnel(cls, db: AsyncSession, tunnel: TunnelModel, scope: TestBatchScope) -> None:
|
||||
await cls._update(db, BizTunnel, BizTunnel.tunnel_id, 'tunnel_id', tunnel, scope)
|
||||
|
||||
@classmethod
|
||||
async def list_warehouse(
|
||||
cls,
|
||||
@@ -475,6 +539,25 @@ class MasterDao:
|
||||
result = await db.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
async def count_tunnel_trace_members(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel_id: int,
|
||||
trace_no: str,
|
||||
scope: TestBatchScope,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(BizPalletInventory)
|
||||
.where(
|
||||
BizPalletInventory.tunnel_id == tunnel_id,
|
||||
BizPalletInventory.trace_no == trace_no,
|
||||
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
|
||||
)
|
||||
)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
@classmethod
|
||||
async def add_pallet_inventory(
|
||||
cls,
|
||||
|
||||
@@ -810,6 +810,10 @@ class OperationDao:
|
||||
conditions.append(BizUsageRecord.usage_no.like(f'%{query_object.usage_no}%'))
|
||||
if query_object.site_id:
|
||||
conditions.append(BizUsageRecord.site_id == query_object.site_id)
|
||||
if query_object.tunnel_id:
|
||||
conditions.append(BizUsageRecord.tunnel_id == query_object.tunnel_id)
|
||||
if query_object.trace_no:
|
||||
conditions.append(BizUsageRecord.trace_no.like(f'%{query_object.trace_no}%'))
|
||||
if query_object.line_id:
|
||||
conditions.append(BizUsageRecord.line_id == query_object.line_id)
|
||||
if query_object.pallet_code:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement, Select, and_, false, or_, select
|
||||
from sqlalchemy import ColumnElement, Select, and_, case, false, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
@@ -15,6 +15,7 @@ from module_business.entity.do.master_do import (
|
||||
BizCustomerSite,
|
||||
BizPallet,
|
||||
BizProductionLine,
|
||||
BizTunnel,
|
||||
BizWarehouse,
|
||||
)
|
||||
from module_business.entity.do.operation_do import (
|
||||
@@ -40,6 +41,7 @@ from module_business.entity.vo.report_vo import (
|
||||
ReturnReportQueryModel,
|
||||
ShipmentReportModel,
|
||||
ShipmentReportQueryModel,
|
||||
TunnelReportQueryModel,
|
||||
)
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
@@ -84,6 +86,31 @@ class ReportDao:
|
||||
"""保留空的可选 join,同时拒绝已存在的跨批次关联记录。"""
|
||||
return or_(id_column.is_(None), batch_scope_condition(scope_column, scope))
|
||||
|
||||
@staticmethod
|
||||
def _tunnel_conditions(
|
||||
query: TunnelReportQueryModel,
|
||||
trace_column: ColumnElement,
|
||||
*tunnels: tuple[ColumnElement, Any],
|
||||
) -> list[ColumnElement[bool]]:
|
||||
"""历史事件可匹配变更前/后隧道;同一组编号、名称须命中同一个隧道。"""
|
||||
conditions = []
|
||||
if query.trace_no:
|
||||
conditions.append(trace_column == query.trace_no)
|
||||
matches = []
|
||||
for id_column, tunnel in tunnels:
|
||||
filters = []
|
||||
if query.tunnel_id:
|
||||
filters.append(id_column == query.tunnel_id)
|
||||
if query.tunnel_code:
|
||||
filters.append(tunnel.tunnel_code.like(f'%{query.tunnel_code}%'))
|
||||
if query.tunnel_name:
|
||||
filters.append(tunnel.tunnel_name.like(f'%{query.tunnel_name}%'))
|
||||
if filters:
|
||||
matches.append(and_(*filters))
|
||||
if matches:
|
||||
conditions.append(or_(*matches))
|
||||
return conditions
|
||||
|
||||
@classmethod
|
||||
def _or_scope_conditions(
|
||||
cls,
|
||||
@@ -158,6 +185,7 @@ class ReportDao:
|
||||
current_warehouse = aliased(BizWarehouse, name='current_warehouse')
|
||||
current_site = aliased(BizCustomerSite, name='current_site')
|
||||
current_line = aliased(BizProductionLine, name='current_line')
|
||||
current_tunnel = aliased(BizTunnel, name='current_tunnel')
|
||||
active_filling = aliased(BizFillingRecord, name='active_filling')
|
||||
|
||||
conditions = [
|
||||
@@ -167,7 +195,11 @@ class ReportDao:
|
||||
cls._joined_scope_condition(current_warehouse.warehouse_id, current_warehouse.test_batch_id, scope),
|
||||
cls._joined_scope_condition(current_site.site_id, current_site.test_batch_id, scope),
|
||||
cls._joined_scope_condition(current_line.line_id, current_line.test_batch_id, scope),
|
||||
cls._joined_scope_condition(current_tunnel.tunnel_id, current_tunnel.test_batch_id, scope),
|
||||
]
|
||||
conditions.extend(cls._tunnel_conditions(
|
||||
query_object, BizPalletInventory.trace_no, (BizPalletInventory.tunnel_id, current_tunnel),
|
||||
))
|
||||
conditions.extend(
|
||||
cls._or_scope_conditions(
|
||||
(BizPalletInventory.warehouse_id, warehouse_ids),
|
||||
@@ -214,6 +246,12 @@ class ReportDao:
|
||||
active_filling.initial_packages.label('initial_packages'),
|
||||
BizPalletInventory.remaining_packages.label('remaining_packages'),
|
||||
BizPalletInventory.updated_at.label('updated_at'),
|
||||
BizPalletInventory.tunnel_id.label('tunnel_id'),
|
||||
current_tunnel.tunnel_code.label('tunnel_code'),
|
||||
current_tunnel.tunnel_name.label('tunnel_name'),
|
||||
BizPalletInventory.trace_no.label('trace_no'),
|
||||
active_filling.production_date.label('production_date'),
|
||||
active_filling.expiry_date.label('expiry_date'),
|
||||
)
|
||||
.select_from(BizPalletInventory)
|
||||
.join(BizPallet, BizPallet.pallet_id == BizPalletInventory.pallet_id)
|
||||
@@ -221,6 +259,7 @@ class ReportDao:
|
||||
.outerjoin(current_warehouse, current_warehouse.warehouse_id == BizPalletInventory.warehouse_id)
|
||||
.outerjoin(current_site, current_site.site_id == BizPalletInventory.site_id)
|
||||
.outerjoin(current_line, current_line.line_id == BizPalletInventory.current_line_id)
|
||||
.outerjoin(current_tunnel, current_tunnel.tunnel_id == BizPalletInventory.tunnel_id)
|
||||
.where(*conditions)
|
||||
.order_by(BizPalletInventory.pallet_id)
|
||||
)
|
||||
@@ -374,14 +413,21 @@ class ReportDao:
|
||||
site_ids: list[int] | None = None,
|
||||
) -> PageModel[ConsumptionReportModel]:
|
||||
operator_user = aliased(SysUser, name='operator_user')
|
||||
usage_tunnel = aliased(BizTunnel, name='usage_tunnel')
|
||||
# 新记录优先使用业务快照;历史空值只回查该次使用引用的装填记录。
|
||||
batch_no = func.coalesce(BizUsageRecord.batch_no_snapshot, BizFillingRecord.batch_no)
|
||||
conditions = [
|
||||
batch_scope_condition(BizUsageRecord.test_batch_id, scope),
|
||||
batch_scope_condition(BizPallet.test_batch_id, scope),
|
||||
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
|
||||
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
|
||||
batch_scope_condition(BizProductionLine.test_batch_id, scope),
|
||||
cls._joined_scope_condition(BizProductionLine.line_id, BizProductionLine.test_batch_id, scope),
|
||||
cls._joined_scope_condition(usage_tunnel.tunnel_id, usage_tunnel.test_batch_id, scope),
|
||||
cls._joined_scope_condition(BizCustomer.customer_id, BizCustomer.test_batch_id, scope),
|
||||
]
|
||||
conditions.extend(cls._tunnel_conditions(
|
||||
query_object, BizUsageRecord.trace_no, (BizUsageRecord.tunnel_id, usage_tunnel),
|
||||
))
|
||||
site_scope = cls._scope_condition(BizUsageRecord.site_id, site_ids)
|
||||
if site_scope is not None:
|
||||
conditions.append(site_scope)
|
||||
@@ -396,7 +442,7 @@ class ReportDao:
|
||||
if query_object.strain_id:
|
||||
conditions.append(BizFillingRecord.strain_id == query_object.strain_id)
|
||||
if query_object.batch_no:
|
||||
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
|
||||
conditions.append(batch_no.like(f'%{query_object.batch_no}%'))
|
||||
conditions.extend(
|
||||
cls._time_conditions(
|
||||
BizUsageRecord.occurred_at,
|
||||
@@ -421,17 +467,25 @@ class ReportDao:
|
||||
BizFillingRecord.strain_id.label('strain_id'),
|
||||
BizFillingRecord.strain_code_snapshot.label('strain_code'),
|
||||
BizFillingRecord.strain_name_snapshot.label('strain_name'),
|
||||
BizFillingRecord.batch_no.label('batch_no'),
|
||||
batch_no.label('batch_no'),
|
||||
BizUsageRecord.used_packages.label('used_packages'),
|
||||
BizUsageRecord.remaining_packages_after.label('remaining_packages_after'),
|
||||
BizUsageRecord.operator_id.label('operator_id'),
|
||||
operator_user.user_name.label('operator_name'),
|
||||
BizUsageRecord.tunnel_id.label('tunnel_id'),
|
||||
usage_tunnel.tunnel_code.label('tunnel_code'),
|
||||
usage_tunnel.tunnel_name.label('tunnel_name'),
|
||||
BizUsageRecord.trace_no.label('trace_no'),
|
||||
BizUsageRecord.batch_no_snapshot.label('batch_no_snapshot'),
|
||||
BizFillingRecord.production_date.label('production_date'),
|
||||
BizFillingRecord.expiry_date.label('expiry_date'),
|
||||
)
|
||||
.select_from(BizUsageRecord)
|
||||
.join(BizPallet, BizPallet.pallet_id == BizUsageRecord.pallet_id)
|
||||
.join(BizFillingRecord, BizFillingRecord.filling_id == BizUsageRecord.active_filling_id)
|
||||
.join(BizCustomerSite, BizCustomerSite.site_id == BizUsageRecord.site_id)
|
||||
.join(BizProductionLine, BizProductionLine.line_id == BizUsageRecord.line_id)
|
||||
.outerjoin(BizProductionLine, BizProductionLine.line_id == BizUsageRecord.line_id)
|
||||
.outerjoin(usage_tunnel, usage_tunnel.tunnel_id == BizUsageRecord.tunnel_id)
|
||||
.outerjoin(BizCustomer, BizCustomer.customer_id == BizCustomerSite.customer_id)
|
||||
.outerjoin(operator_user, operator_user.user_id == BizUsageRecord.operator_id)
|
||||
.where(*conditions)
|
||||
@@ -450,13 +504,23 @@ class ReportDao:
|
||||
) -> PageModel[ReturnReportModel]:
|
||||
source_site = aliased(BizCustomerSite, name='source_site')
|
||||
target_warehouse = aliased(BizWarehouse, name='target_warehouse')
|
||||
return_tunnel = aliased(BizTunnel, name='return_tunnel')
|
||||
return_filling = aliased(BizFillingRecord, name='return_filling')
|
||||
batch_no = func.coalesce(BizReturnItem.batch_no_snapshot, return_filling.batch_no)
|
||||
conditions = [
|
||||
batch_scope_condition(BizReturnOrder.test_batch_id, scope),
|
||||
batch_scope_condition(BizReturnItem.test_batch_id, scope),
|
||||
batch_scope_condition(BizPallet.test_batch_id, scope),
|
||||
cls._joined_scope_condition(source_site.site_id, source_site.test_batch_id, scope),
|
||||
cls._joined_scope_condition(target_warehouse.warehouse_id, target_warehouse.test_batch_id, scope),
|
||||
cls._joined_scope_condition(return_tunnel.tunnel_id, return_tunnel.test_batch_id, scope),
|
||||
cls._joined_scope_condition(return_filling.filling_id, return_filling.test_batch_id, scope),
|
||||
]
|
||||
conditions.extend(cls._tunnel_conditions(
|
||||
query_object, BizReturnItem.trace_no_snapshot, (BizReturnItem.tunnel_id_snapshot, return_tunnel),
|
||||
))
|
||||
if query_object.batch_no:
|
||||
conditions.append(batch_no.like(f'%{query_object.batch_no}%'))
|
||||
conditions.extend(
|
||||
cls._or_scope_conditions(
|
||||
(BizReturnOrder.source_site_id, site_ids),
|
||||
@@ -496,12 +560,22 @@ class ReportDao:
|
||||
BizPallet.pallet_code.label('pallet_code'),
|
||||
BizReturnItem.active_filling_id_snapshot.label('active_filling_id_snapshot'),
|
||||
BizReturnItem.remaining_packages_snapshot.label('remaining_packages_snapshot'),
|
||||
BizReturnItem.tunnel_id_snapshot.label('tunnel_id'),
|
||||
return_tunnel.tunnel_code.label('tunnel_code'),
|
||||
return_tunnel.tunnel_name.label('tunnel_name'),
|
||||
BizReturnItem.trace_no_snapshot.label('trace_no'),
|
||||
BizReturnItem.batch_no_snapshot.label('batch_no_snapshot'),
|
||||
batch_no.label('batch_no'),
|
||||
return_filling.production_date.label('production_date'),
|
||||
return_filling.expiry_date.label('expiry_date'),
|
||||
)
|
||||
.select_from(BizReturnOrder)
|
||||
.join(BizReturnItem, BizReturnItem.return_id == BizReturnOrder.return_id)
|
||||
.join(BizPallet, BizPallet.pallet_id == BizReturnItem.pallet_id)
|
||||
.outerjoin(source_site, source_site.site_id == BizReturnOrder.source_site_id)
|
||||
.outerjoin(target_warehouse, target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id)
|
||||
.outerjoin(return_tunnel, return_tunnel.tunnel_id == BizReturnItem.tunnel_id_snapshot)
|
||||
.outerjoin(return_filling, return_filling.filling_id == BizReturnItem.active_filling_id_snapshot)
|
||||
.where(*conditions)
|
||||
.order_by(BizReturnOrder.return_id, BizReturnItem.return_item_id)
|
||||
)
|
||||
@@ -522,6 +596,41 @@ class ReportDao:
|
||||
after_site = aliased(BizCustomerSite, name='after_site')
|
||||
before_line = aliased(BizProductionLine, name='before_line')
|
||||
after_line = aliased(BizProductionLine, name='after_line')
|
||||
before_tunnel = aliased(BizTunnel, name='before_tunnel')
|
||||
after_tunnel = aliased(BizTunnel, name='after_tunnel')
|
||||
trace_return_item = aliased(BizReturnItem, name='trace_return_item')
|
||||
trace_usage = aliased(BizUsageRecord, name='trace_usage')
|
||||
trace_shipment_item = aliased(BizShipmentItem, name='trace_shipment_item')
|
||||
trace_filling = aliased(BizFillingRecord, name='trace_filling')
|
||||
prior_filling_event = aliased(BizPalletEvent, name='prior_filling_event')
|
||||
# 入隧事件来源是隧道而非装填记录,按托盘事件序号回查当时已发生的最后一次装填。
|
||||
# 其他事件只使用来源单据的装填引用;引用缺失时保留空日期,不借用后续重装填。
|
||||
prior_filling_id = (
|
||||
select(prior_filling_event.source_id)
|
||||
.where(
|
||||
prior_filling_event.pallet_id == BizPalletEvent.pallet_id,
|
||||
prior_filling_event.event_seq < BizPalletEvent.event_seq,
|
||||
prior_filling_event.event_type == PalletEventType.FILLING_CONFIRMED.value,
|
||||
prior_filling_event.source_type == 'FILLING',
|
||||
batch_scope_condition(prior_filling_event.test_batch_id, scope),
|
||||
)
|
||||
.order_by(prior_filling_event.event_seq.desc())
|
||||
.limit(1)
|
||||
.correlate(BizPalletEvent)
|
||||
.scalar_subquery()
|
||||
)
|
||||
trace_filling_id = case(
|
||||
(BizPalletEvent.source_type == 'FILLING', BizPalletEvent.source_id),
|
||||
(BizPalletEvent.source_type == 'USAGE', trace_usage.active_filling_id),
|
||||
(BizPalletEvent.source_type == 'RETURN', trace_return_item.active_filling_id_snapshot),
|
||||
(BizPalletEvent.source_type == 'SHIPMENT', trace_shipment_item.active_filling_id),
|
||||
(BizPalletEvent.event_type == PalletEventType.TUNNEL_ENTERED.value, prior_filling_id),
|
||||
else_=None,
|
||||
)
|
||||
# 部分退回事件未保存隧道;只从同单同托盘的冻结退回项补读,不读取当前库存。
|
||||
before_tunnel_id = func.coalesce(BizPalletEvent.before_tunnel_id, trace_return_item.tunnel_id_snapshot)
|
||||
trace_no = func.coalesce(BizPalletEvent.trace_no, trace_return_item.trace_no_snapshot)
|
||||
batch_no = func.coalesce(BizPalletEvent.batch_no_snapshot, trace_return_item.batch_no_snapshot)
|
||||
operator_user = aliased(SysUser, name='operator_user')
|
||||
conditions = [
|
||||
batch_scope_condition(BizPalletEvent.test_batch_id, scope),
|
||||
@@ -532,7 +641,20 @@ class ReportDao:
|
||||
cls._joined_scope_condition(after_site.site_id, after_site.test_batch_id, scope),
|
||||
cls._joined_scope_condition(before_line.line_id, before_line.test_batch_id, scope),
|
||||
cls._joined_scope_condition(after_line.line_id, after_line.test_batch_id, scope),
|
||||
cls._joined_scope_condition(before_tunnel.tunnel_id, before_tunnel.test_batch_id, scope),
|
||||
cls._joined_scope_condition(after_tunnel.tunnel_id, after_tunnel.test_batch_id, scope),
|
||||
cls._joined_scope_condition(trace_return_item.return_item_id, trace_return_item.test_batch_id, scope),
|
||||
cls._joined_scope_condition(trace_usage.usage_id, trace_usage.test_batch_id, scope),
|
||||
cls._joined_scope_condition(trace_shipment_item.shipment_item_id, trace_shipment_item.test_batch_id, scope),
|
||||
cls._joined_scope_condition(trace_filling.filling_id, trace_filling.test_batch_id, scope),
|
||||
]
|
||||
conditions.extend(cls._tunnel_conditions(
|
||||
query_object, trace_no,
|
||||
(before_tunnel_id, before_tunnel),
|
||||
(BizPalletEvent.after_tunnel_id, after_tunnel),
|
||||
))
|
||||
if query_object.batch_no:
|
||||
conditions.append(batch_no.like(f'%{query_object.batch_no}%'))
|
||||
conditions.extend(cls._pallet_trace_scope_conditions(warehouse_ids, site_ids))
|
||||
if query_object.pallet_code:
|
||||
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
|
||||
@@ -574,6 +696,19 @@ class ReportDao:
|
||||
BizPalletEvent.operator_id.label('operator_id'),
|
||||
operator_user.user_name.label('operator_name'),
|
||||
BizPalletEvent.occurred_at.label('occurred_at'),
|
||||
func.coalesce(BizPalletEvent.after_tunnel_id, before_tunnel_id).label('tunnel_id'),
|
||||
func.coalesce(after_tunnel.tunnel_code, before_tunnel.tunnel_code).label('tunnel_code'),
|
||||
func.coalesce(after_tunnel.tunnel_name, before_tunnel.tunnel_name).label('tunnel_name'),
|
||||
trace_no.label('trace_no'),
|
||||
batch_no.label('batch_no_snapshot'),
|
||||
before_tunnel_id.label('before_tunnel_id'),
|
||||
before_tunnel.tunnel_code.label('before_tunnel_code'),
|
||||
before_tunnel.tunnel_name.label('before_tunnel_name'),
|
||||
BizPalletEvent.after_tunnel_id.label('after_tunnel_id'),
|
||||
after_tunnel.tunnel_code.label('after_tunnel_code'),
|
||||
after_tunnel.tunnel_name.label('after_tunnel_name'),
|
||||
trace_filling.production_date.label('production_date'),
|
||||
trace_filling.expiry_date.label('expiry_date'),
|
||||
)
|
||||
.select_from(BizPalletEvent)
|
||||
.join(BizPallet, BizPallet.pallet_id == BizPalletEvent.pallet_id)
|
||||
@@ -583,6 +718,27 @@ class ReportDao:
|
||||
.outerjoin(after_site, after_site.site_id == BizPalletEvent.after_site_id)
|
||||
.outerjoin(before_line, before_line.line_id == BizPalletEvent.before_line_id)
|
||||
.outerjoin(after_line, after_line.line_id == BizPalletEvent.after_line_id)
|
||||
.outerjoin(trace_return_item, and_(
|
||||
BizPalletEvent.source_type == 'RETURN',
|
||||
trace_return_item.return_id == BizPalletEvent.source_id,
|
||||
trace_return_item.pallet_id == BizPalletEvent.pallet_id,
|
||||
))
|
||||
.outerjoin(trace_usage, and_(
|
||||
BizPalletEvent.source_type == 'USAGE',
|
||||
trace_usage.usage_id == BizPalletEvent.source_id,
|
||||
trace_usage.pallet_id == BizPalletEvent.pallet_id,
|
||||
))
|
||||
.outerjoin(trace_shipment_item, and_(
|
||||
BizPalletEvent.source_type == 'SHIPMENT',
|
||||
trace_shipment_item.shipment_id == BizPalletEvent.source_id,
|
||||
trace_shipment_item.pallet_id == BizPalletEvent.pallet_id,
|
||||
))
|
||||
.outerjoin(trace_filling, and_(
|
||||
trace_filling.filling_id == trace_filling_id,
|
||||
trace_filling.pallet_id == BizPalletEvent.pallet_id,
|
||||
))
|
||||
.outerjoin(before_tunnel, before_tunnel.tunnel_id == before_tunnel_id)
|
||||
.outerjoin(after_tunnel, after_tunnel.tunnel_id == BizPalletEvent.after_tunnel_id)
|
||||
.outerjoin(operator_user, operator_user.user_id == BizPalletEvent.operator_id)
|
||||
.where(*conditions)
|
||||
.order_by(BizPalletEvent.occurred_at, BizPalletEvent.event_id)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.operation_do import (
|
||||
BizFillingRecord,
|
||||
BizPalletInventory,
|
||||
BizShipmentItem,
|
||||
BizShipmentOrder,
|
||||
)
|
||||
|
||||
|
||||
class ShipmentLabelDao:
|
||||
"""只读标签资料;外连接保留失效明细,由服务拒绝不完整的整单打印。"""
|
||||
|
||||
@staticmethod
|
||||
async def get_label_sources(
|
||||
db: AsyncSession,
|
||||
shipment: BizShipmentOrder,
|
||||
scope: TestBatchScope,
|
||||
) -> list[tuple[BizShipmentItem, BizPallet | None, BizFillingRecord | None, int | None]]:
|
||||
draft = shipment.status == DocumentStatus.DRAFT.value
|
||||
quantity = BizPalletInventory.remaining_packages if draft else BizShipmentItem.package_qty
|
||||
statement = (
|
||||
select(BizShipmentItem, BizPallet, BizFillingRecord, quantity)
|
||||
.select_from(BizShipmentItem)
|
||||
.outerjoin(BizPallet, and_(
|
||||
BizShipmentItem.pallet_id == BizPallet.pallet_id,
|
||||
batch_scope_condition(BizPallet.test_batch_id, scope),
|
||||
))
|
||||
)
|
||||
if draft:
|
||||
statement = statement.outerjoin(BizPalletInventory, and_(
|
||||
BizShipmentItem.pallet_id == BizPalletInventory.pallet_id,
|
||||
BizPalletInventory.warehouse_id == shipment.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),
|
||||
))
|
||||
# 已确认单只关联确认时的装填记录,即使周期已关闭、托盘已重新装填也可补打。
|
||||
filling_id = BizPalletInventory.active_filling_id if draft else BizShipmentItem.active_filling_id
|
||||
filling_conditions = [
|
||||
filling_id == BizFillingRecord.filling_id,
|
||||
BizShipmentItem.pallet_id == BizFillingRecord.pallet_id,
|
||||
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
|
||||
]
|
||||
if draft:
|
||||
filling_conditions.append(BizFillingRecord.active_slot == 'ACTIVE')
|
||||
statement = (
|
||||
statement.outerjoin(BizFillingRecord, and_(*filling_conditions))
|
||||
.where(
|
||||
BizShipmentItem.shipment_id == shipment.shipment_id,
|
||||
batch_scope_condition(BizShipmentItem.test_batch_id, scope),
|
||||
)
|
||||
.order_by(BizShipmentItem.shipment_item_id)
|
||||
)
|
||||
return list((await db.execute(statement)).all())
|
||||
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.operation_do import BizShipmentOrder
|
||||
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptQueryModel
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
|
||||
class ShipmentReceiptDao:
|
||||
@staticmethod
|
||||
async def list_orders(
|
||||
db: AsyncSession, query: ShipmentReceiptQueryModel, scope: TestBatchScope, site_ids: list[int] | None
|
||||
) -> PageModel:
|
||||
# 接收方只需要目标现场范围,不扩大原发货操作的双范围权限。
|
||||
conditions = OperationDao._shipment_scope_conditions(None, site_ids, scope)
|
||||
conditions.append(BizShipmentOrder.status == 'CONFIRMED')
|
||||
if query.shipment_no:
|
||||
conditions.append(BizShipmentOrder.shipment_no.like(f'%{query.shipment_no}%'))
|
||||
if query.target_site_id:
|
||||
conditions.append(BizShipmentOrder.target_site_id == query.target_site_id)
|
||||
if query.source_warehouse_id:
|
||||
conditions.append(BizShipmentOrder.source_warehouse_id == query.source_warehouse_id)
|
||||
if query.receipt_state:
|
||||
conditions.append(BizShipmentOrder.receipt_state == query.receipt_state)
|
||||
conditions.extend(
|
||||
OperationDao._time_conditions(BizShipmentOrder.confirmed_at, query.begin_time, query.end_time)
|
||||
)
|
||||
statement = (
|
||||
select(BizShipmentOrder)
|
||||
.where(*conditions)
|
||||
.order_by(BizShipmentOrder.confirmed_at, BizShipmentOrder.shipment_id)
|
||||
)
|
||||
return await PageUtil.paginate(db, statement, query.page_num, query.page_size, is_page=True)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""按真实收货时间推荐可用物料,不推断历史到货时间。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import 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
|
||||
|
||||
|
||||
class UsageFifoDao:
|
||||
@staticmethod
|
||||
async def recommendation(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
site_id: int,
|
||||
strain_id: int,
|
||||
tunnel_id: int | None,
|
||||
trace_no: str | None,
|
||||
scope: TestBatchScope,
|
||||
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 = (
|
||||
select(
|
||||
pallet.pallet_id,
|
||||
pallet.pallet_code,
|
||||
filling.batch_no,
|
||||
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)
|
||||
)
|
||||
if for_update:
|
||||
# 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。
|
||||
# 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。
|
||||
# 此时已持有所选库存及现场/隧道锁,不能等待其他候选库存造成反向锁依赖。
|
||||
# NOWAIT 不会跳过早到托盘:冲突令整个命令回滚,再按原幂等键有限重试。
|
||||
statement = statement.with_for_update(nowait=True)
|
||||
known = await db.execute(statement)
|
||||
first = known.mappings().first()
|
||||
if for_update:
|
||||
return (dict(first) if first is not None else None, 0)
|
||||
unknown = await db.execute(
|
||||
select(func.count()).select_from(
|
||||
base.where(inventory.site_arrived_at.is_(None)).subquery(),
|
||||
)
|
||||
)
|
||||
return (dict(first) if first is not None else None, unknown.scalar_one())
|
||||
@@ -5,6 +5,7 @@ class PalletFlowState(str, Enum):
|
||||
UNSTOCKED = 'UNSTOCKED'
|
||||
EMPTY_IN_WAREHOUSE = 'EMPTY_IN_WAREHOUSE'
|
||||
FILLED_IN_WAREHOUSE = 'FILLED_IN_WAREHOUSE'
|
||||
IN_TRANSIT = 'IN_TRANSIT'
|
||||
AT_SITE_UNUSED = 'AT_SITE_UNUSED'
|
||||
AT_SITE_PARTIAL = 'AT_SITE_PARTIAL'
|
||||
AT_SITE_EMPTY = 'AT_SITE_EMPTY'
|
||||
@@ -25,6 +26,8 @@ class PalletEventType(str, Enum):
|
||||
INBOUND_CONFIRMED = 'INBOUND_CONFIRMED'
|
||||
FILLING_CONFIRMED = 'FILLING_CONFIRMED'
|
||||
SHIPMENT_CONFIRMED = 'SHIPMENT_CONFIRMED'
|
||||
SHIPMENT_RECEIVED = 'SHIPMENT_RECEIVED'
|
||||
TUNNEL_ENTERED = 'TUNNEL_ENTERED'
|
||||
USAGE_RECORDED = 'USAGE_RECORDED'
|
||||
RETURN_REQUESTED = 'RETURN_REQUESTED'
|
||||
RETURN_CONFIRMED = 'RETURN_CONFIRMED'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""只比较真实到货时间,不将未知时间或托盘编号作为先后依据。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def requires_fifo_skip_reason(
|
||||
pallet_id: int, arrived_at: datetime | None, candidate: dict[str, Any] | None
|
||||
) -> bool:
|
||||
return bool(
|
||||
arrived_at is not None
|
||||
and candidate is not None
|
||||
and candidate['pallet_id'] != pallet_id
|
||||
and candidate['site_arrived_at'] < arrived_at
|
||||
)
|
||||
@@ -13,7 +13,8 @@ class PalletStateMachine:
|
||||
PalletFlowState.EMPTY_IN_WAREHOUSE,
|
||||
PalletFlowState.FILLED_IN_WAREHOUSE,
|
||||
),
|
||||
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.AT_SITE_UNUSED),
|
||||
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.IN_TRANSIT),
|
||||
(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED),
|
||||
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_PARTIAL),
|
||||
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_EMPTY),
|
||||
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.RETURNING),
|
||||
|
||||
@@ -15,6 +15,7 @@ from module_business.entity.do.master_do import (
|
||||
BizPallet,
|
||||
BizProductionLine,
|
||||
BizStrain,
|
||||
BizTunnel,
|
||||
BizWarehouse,
|
||||
)
|
||||
from module_business.entity.do.operation_do import (
|
||||
@@ -42,6 +43,7 @@ BATCH_MODELS = {
|
||||
BizProductionLine.line_id,
|
||||
BizProductionLine.line_code,
|
||||
),
|
||||
'biz_tunnel': (BizTunnel, BizTunnel.tunnel_id, BizTunnel.tunnel_code),
|
||||
'biz_warehouse': (BizWarehouse, BizWarehouse.warehouse_id, BizWarehouse.warehouse_code),
|
||||
'biz_pallet': (BizPallet, BizPallet.pallet_id, BizPallet.pallet_code),
|
||||
'biz_inbound_order': (BizInboundOrder, BizInboundOrder.inbound_id, BizInboundOrder.inbound_no),
|
||||
@@ -85,6 +87,7 @@ BATCH_LABELS = {
|
||||
'biz_customer': '客户',
|
||||
'biz_customer_site': '客户现场',
|
||||
'biz_production_line': '生产线',
|
||||
'biz_tunnel': '隧道',
|
||||
'biz_warehouse': '仓库',
|
||||
'biz_pallet': '托盘',
|
||||
'biz_inbound_order': '入库单',
|
||||
@@ -111,6 +114,7 @@ _DELETE_TIE_BREAK_ORDER = (
|
||||
'biz_pallet_event',
|
||||
'biz_filling_record',
|
||||
'biz_production_line',
|
||||
'biz_tunnel',
|
||||
'biz_customer_site',
|
||||
'biz_customer',
|
||||
'biz_warehouse',
|
||||
@@ -123,6 +127,7 @@ ROOT_TABLES = {
|
||||
'customer': 'biz_customer',
|
||||
'site': 'biz_customer_site',
|
||||
'line': 'biz_production_line',
|
||||
'tunnel': 'biz_tunnel',
|
||||
'warehouse': 'biz_warehouse',
|
||||
'pallet': 'biz_pallet',
|
||||
'inbound': 'biz_inbound_order',
|
||||
@@ -136,6 +141,7 @@ _EVENT_SOURCE_TABLES = {
|
||||
'INBOUND': 'biz_inbound_order',
|
||||
'FILLING': 'biz_filling_record',
|
||||
'SHIPMENT': 'biz_shipment_order',
|
||||
'TUNNEL': 'biz_tunnel',
|
||||
'USAGE': 'biz_usage_record',
|
||||
'RETURN': 'biz_return_order',
|
||||
}
|
||||
|
||||
@@ -193,6 +193,47 @@ class BizProductionLine(Base):
|
||||
)
|
||||
|
||||
|
||||
class BizTunnel(Base):
|
||||
"""客户现场隧道主数据表。"""
|
||||
|
||||
__tablename__ = 'biz_tunnel'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('site_id', 'tunnel_code', name='uq_biz_tunnel_site_code'),
|
||||
CheckConstraint("status IN ('0', '1')", name='ck_biz_tunnel_status'),
|
||||
{'comment': '客户现场隧道主数据表'},
|
||||
)
|
||||
|
||||
tunnel_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='隧道ID')
|
||||
test_batch_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_test_batch.batch_id'),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment='测试数据批次ID;NULL表示正式数据',
|
||||
)
|
||||
site_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_customer_site.site_id'),
|
||||
nullable=False,
|
||||
comment='现场ID',
|
||||
)
|
||||
tunnel_code = Column(String(64), nullable=False, comment='隧道编号')
|
||||
tunnel_name = Column(String(100), nullable=False, comment='隧道名称')
|
||||
current_trace_no = Column(String(64), nullable=True, comment='当前隧道物料溯源号')
|
||||
current_trace_started_at = Column(DateTime, nullable=True, comment='当前溯源批次开始时间')
|
||||
status = Column(CHAR(1), nullable=False, server_default='0', comment='状态(0正常 1停用)')
|
||||
create_by = Column(String(64), nullable=True, server_default='', comment='创建者')
|
||||
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
|
||||
update_by = Column(String(64), nullable=True, server_default='', comment='更新者')
|
||||
update_time = Column(DateTime, nullable=True, default=datetime.now, comment='更新时间')
|
||||
remark = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
|
||||
comment='备注',
|
||||
)
|
||||
|
||||
|
||||
class BizPallet(Base):
|
||||
"""托盘资产主数据表。"""
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
|
||||
from config.database import Base
|
||||
from config.env import DataBaseConfig
|
||||
@@ -99,6 +110,11 @@ class BizFillingRecord(Base):
|
||||
),
|
||||
UniqueConstraint('pallet_id', 'active_slot', name='uq_biz_filling_record_pallet_active_slot'),
|
||||
CheckConstraint('initial_packages > 0', name='ck_biz_filling_record_initial_packages'),
|
||||
CheckConstraint(
|
||||
'(production_date IS NULL AND expiry_date IS NULL) OR '
|
||||
'(production_date IS NOT NULL AND expiry_date IS NOT NULL AND expiry_date >= production_date)',
|
||||
name='ck_biz_filling_record_material_dates',
|
||||
),
|
||||
CheckConstraint(
|
||||
"active_slot IS NULL OR active_slot = 'ACTIVE'",
|
||||
name='ck_biz_filling_record_active_slot',
|
||||
@@ -126,6 +142,8 @@ class BizFillingRecord(Base):
|
||||
strain_code_snapshot = Column(String(64), nullable=False, comment='菌种编码快照')
|
||||
strain_name_snapshot = Column(String(100), nullable=False, comment='菌种名称快照')
|
||||
batch_no = Column(String(64), nullable=False, comment='生产批次')
|
||||
production_date = Column(Date, nullable=True, comment='来源生产日期;历史未登记为空')
|
||||
expiry_date = Column(Date, nullable=True, comment='有效期至;历史未登记为空')
|
||||
package_spec = Column(String(64), nullable=False, comment='包装规格')
|
||||
initial_packages = Column(Integer, nullable=False, comment='初始包数')
|
||||
active_slot = Column(String(16), nullable=True, comment='活动装填槽')
|
||||
@@ -158,6 +176,8 @@ class BizShipmentOrder(Base):
|
||||
name='uq_biz_shipment_order_route_confirm_idempotency',
|
||||
),
|
||||
CheckConstraint("status IN ('DRAFT', 'CONFIRMED')", name='ck_biz_shipment_order_status'),
|
||||
CheckConstraint("receipt_state IN ('PENDING', 'RECEIVED', 'LEGACY')", name='ck_biz_shipment_order_receipt_state'),
|
||||
UniqueConstraint('target_site_id', 'receipt_idempotency_key', name='uq_biz_shipment_receipt_site_key'),
|
||||
{'comment': '整托发货单'},
|
||||
)
|
||||
|
||||
@@ -187,6 +207,11 @@ class BizShipmentOrder(Base):
|
||||
confirm_request_hash = Column(String(64), nullable=True, comment='确认请求摘要')
|
||||
confirmed_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=True, comment='确认人ID')
|
||||
confirmed_at = Column(DateTime, nullable=True, comment='确认时间')
|
||||
receipt_state = Column(String(16), nullable=False, server_default='PENDING', comment='收货状态;LEGACY表示历史未登记')
|
||||
received_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=True, comment='收货核对人ID')
|
||||
received_at = Column(DateTime, nullable=True, comment='实际收货核对时间')
|
||||
receipt_idempotency_key = Column(String(64), nullable=True, comment='收货确认幂等键')
|
||||
receipt_request_hash = Column(String(64), nullable=True, comment='收货核对请求摘要')
|
||||
create_by = Column(String(64), nullable=True, server_default='', comment='创建者')
|
||||
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
|
||||
update_by = Column(String(64), nullable=True, server_default='', comment='更新者')
|
||||
@@ -275,13 +300,23 @@ class BizUsageRecord(Base):
|
||||
comment='活动装填记录ID',
|
||||
)
|
||||
site_id = Column(BigInteger, ForeignKey('biz_customer_site.site_id'), nullable=False, comment='现场ID')
|
||||
line_id = Column(BigInteger, ForeignKey('biz_production_line.line_id'), nullable=False, comment='产线ID')
|
||||
tunnel_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='隧道ID;历史记录可能为空',
|
||||
)
|
||||
trace_no = Column(String(64), nullable=True, comment='隧道物料内部溯源号;历史记录可能为空')
|
||||
batch_no_snapshot = Column(String(64), nullable=True, comment='生产批次快照;历史记录可能为空')
|
||||
line_id = Column(BigInteger, ForeignKey('biz_production_line.line_id'), nullable=True, comment='产线ID(可选)')
|
||||
used_packages = Column(Integer, nullable=False, comment='本次使用包数')
|
||||
remaining_packages_after = Column(Integer, nullable=False, comment='使用后剩余包数')
|
||||
idempotency_key = Column(String(64), nullable=False, comment='幂等键')
|
||||
request_hash = Column(String(64), nullable=False, comment='请求摘要')
|
||||
operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID')
|
||||
occurred_at = Column(DateTime, nullable=False, comment='发生时间')
|
||||
fifo_skip_reason = Column(String(500), nullable=True, comment='跳过先到托盘原因')
|
||||
fifo_recommendation = Column(JSON, nullable=True, comment='跳过时先到托盘身份及到货时间快照')
|
||||
|
||||
|
||||
class BizReturnOrder(Base):
|
||||
@@ -387,6 +422,14 @@ class BizReturnItem(Base):
|
||||
nullable=True,
|
||||
comment='申请时活动装填记录快照',
|
||||
)
|
||||
tunnel_id_snapshot = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='申请时隧道ID快照',
|
||||
)
|
||||
trace_no_snapshot = Column(String(64), nullable=True, comment='申请时物料溯源号快照')
|
||||
batch_no_snapshot = Column(String(64), nullable=True, comment='申请时生产批次快照')
|
||||
remaining_packages_snapshot = Column(Integer, nullable=False, comment='申请时剩余包数快照')
|
||||
create_by = Column(String(64), nullable=True, server_default='', comment='创建者')
|
||||
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
|
||||
@@ -399,7 +442,7 @@ class BizPalletInventory(Base):
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"flow_state IN ('UNSTOCKED', 'EMPTY_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE', "
|
||||
"'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY', 'RETURNING')",
|
||||
"'IN_TRANSIT', 'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY', 'RETURNING')",
|
||||
name='ck_biz_pallet_inventory_flow_state',
|
||||
),
|
||||
CheckConstraint('remaining_packages >= 0', name='ck_biz_pallet_inventory_remaining_packages'),
|
||||
@@ -444,6 +487,13 @@ class BizPalletInventory(Base):
|
||||
nullable=True,
|
||||
comment='当前现场ID',
|
||||
)
|
||||
tunnel_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='当前隧道ID',
|
||||
)
|
||||
trace_no = Column(String(64), nullable=True, comment='当前隧道物料溯源号')
|
||||
current_line_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_production_line.line_id'),
|
||||
@@ -452,6 +502,7 @@ class BizPalletInventory(Base):
|
||||
)
|
||||
flow_state = Column(String(32), nullable=False, server_default='UNSTOCKED', comment='托盘流转状态')
|
||||
remaining_packages = Column(Integer, nullable=False, server_default='0', comment='当前剩余包数')
|
||||
site_arrived_at = Column(DateTime, nullable=True, comment='本轮实际收货核对时间;历史未登记为空')
|
||||
last_event_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_pallet_event.event_id'),
|
||||
@@ -471,7 +522,7 @@ class BizPalletEvent(Base):
|
||||
UniqueConstraint('pallet_id', 'idempotency_key', name='uq_biz_pallet_event_pallet_idempotency'),
|
||||
CheckConstraint(
|
||||
"event_type IN ('INBOUND_CONFIRMED', 'FILLING_CONFIRMED', 'SHIPMENT_CONFIRMED', "
|
||||
"'USAGE_RECORDED', 'RETURN_REQUESTED', 'RETURN_CONFIRMED')",
|
||||
"'SHIPMENT_RECEIVED', 'TUNNEL_ENTERED', 'USAGE_RECORDED', 'RETURN_REQUESTED', 'RETURN_CONFIRMED')",
|
||||
name='ck_biz_pallet_event_event_type',
|
||||
),
|
||||
{'comment': '托盘只追加流转事件'},
|
||||
@@ -504,6 +555,12 @@ class BizPalletEvent(Base):
|
||||
nullable=True,
|
||||
comment='变更前现场ID',
|
||||
)
|
||||
before_tunnel_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='变更前隧道ID',
|
||||
)
|
||||
before_line_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_production_line.line_id'),
|
||||
@@ -522,6 +579,12 @@ class BizPalletEvent(Base):
|
||||
nullable=True,
|
||||
comment='变更后现场ID',
|
||||
)
|
||||
after_tunnel_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_tunnel.tunnel_id'),
|
||||
nullable=True,
|
||||
comment='变更后隧道ID',
|
||||
)
|
||||
after_line_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('biz_production_line.line_id'),
|
||||
@@ -530,6 +593,10 @@ class BizPalletEvent(Base):
|
||||
)
|
||||
before_remaining_packages = Column(Integer, nullable=False, comment='变更前剩余包数')
|
||||
after_remaining_packages = Column(Integer, nullable=False, comment='变更后剩余包数')
|
||||
trace_no = Column(String(64), nullable=True, comment='事件关联物料溯源号')
|
||||
batch_no_snapshot = Column(String(64), nullable=True, comment='事件生产批次快照')
|
||||
fifo_skip_reason = Column(String(500), nullable=True, comment='跳过先到托盘原因')
|
||||
fifo_recommendation = Column(JSON, nullable=True, comment='跳过时先到托盘身份及到货时间快照')
|
||||
operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID')
|
||||
occurred_at = Column(DateTime, nullable=False, comment='发生时间')
|
||||
idempotency_key = Column(String(64), nullable=False, comment='幂等键')
|
||||
|
||||
@@ -240,6 +240,64 @@ class DeleteLineModel(BaseModel):
|
||||
_validate_unique_ids = field_validator('line_ids')(_ensure_unique_ids)
|
||||
|
||||
|
||||
class TunnelModel(BaseModel):
|
||||
"""隧道主数据写入及展示模型。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
extra='forbid',
|
||||
)
|
||||
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
|
||||
site_id: int = Field(gt=0, description='现场ID')
|
||||
tunnel_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='隧道编号')
|
||||
tunnel_name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='隧道名称')
|
||||
status: Literal['0', '1'] = Field(default='0', description='状态(0正常 1停用)')
|
||||
current_trace_no: str | None = Field(default=None, max_length=64, description='当前物料溯源号')
|
||||
current_trace_started_at: datetime | None = Field(default=None, description='当前溯源批次开始时间')
|
||||
create_by: str | None = Field(default=None, max_length=64, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
update_by: str | None = Field(default=None, max_length=64, description='更新者')
|
||||
update_time: datetime | None = Field(default=None, description='更新时间')
|
||||
remark: str | None = Field(default=None, max_length=500, description='备注')
|
||||
|
||||
|
||||
class TunnelPageQueryModel(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='每页记录数')
|
||||
site_id: int | None = Field(default=None, gt=0, description='现场ID')
|
||||
tunnel_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='隧道编号')
|
||||
tunnel_name: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
pattern=r'\S',
|
||||
description='隧道名称',
|
||||
)
|
||||
status: Literal['0', '1'] | None = Field(default=None, description='状态')
|
||||
|
||||
|
||||
class DeleteTunnelModel(BaseModel):
|
||||
"""删除隧道模型。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
extra='forbid',
|
||||
)
|
||||
|
||||
tunnel_ids: list[PositiveId] = Field(min_length=1, description='需要删除的隧道ID')
|
||||
|
||||
_validate_unique_ids = field_validator('tunnel_ids')(_ensure_unique_ids)
|
||||
|
||||
|
||||
class WarehouseModel(BaseModel):
|
||||
"""仓库主数据写入及展示模型。"""
|
||||
|
||||
@@ -329,7 +387,7 @@ class PalletCreateModel(BaseModel):
|
||||
)
|
||||
|
||||
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
capacity_packages: int = Field(strict=True, gt=0, description='容量包数')
|
||||
capacity_packages: Literal[100] = Field(description='容量包数(固定 100 包)')
|
||||
remark: str | None = Field(default=None, max_length=500, description='备注')
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
@@ -16,6 +16,10 @@ def _ensure_unique_ids(value: list[int]) -> list[int]:
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_fifo_reason(value: str | None) -> str | None:
|
||||
return (value.strip() or None) if isinstance(value, str) else value
|
||||
|
||||
|
||||
def _parse_page_query_date(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.strptime(value, '%Y-%m-%d')
|
||||
@@ -98,10 +102,18 @@ class FillingCreateModel(BaseModel):
|
||||
warehouse_id: int = Field(gt=0, description='仓库ID')
|
||||
strain_id: int = Field(gt=0, description='菌种ID')
|
||||
batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次')
|
||||
production_date: date = Field(description='发货来源生产日期')
|
||||
expiry_date: date = Field(description='有效期至')
|
||||
package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格')
|
||||
initial_packages: int = Field(strict=True, gt=0, description='初始包数')
|
||||
initial_packages: Literal[100] = Field(description='初始包数(固定 100 包)')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_material_dates(self) -> 'FillingCreateModel':
|
||||
if self.expiry_date < self.production_date:
|
||||
raise ValueError('有效期不能早于生产日期')
|
||||
return self
|
||||
|
||||
|
||||
class ShipmentCreateModel(BaseModel):
|
||||
"""整托发货草稿新增模型。"""
|
||||
@@ -149,9 +161,41 @@ class UsageCreateModel(BaseModel):
|
||||
)
|
||||
|
||||
qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识')
|
||||
line_id: int = Field(gt=0, description='产线ID')
|
||||
tunnel_id: int = Field(gt=0, description='隧道ID')
|
||||
line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)')
|
||||
used_packages: int = Field(strict=True, gt=0, description='本次使用包数')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
|
||||
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
|
||||
|
||||
_normalize_reason = field_validator('fifo_skip_reason', mode='before')(_normalize_fifo_reason)
|
||||
|
||||
|
||||
class TunnelEntryCreateModel(BaseModel):
|
||||
"""托盘扫码入隧命令模型。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
extra='forbid',
|
||||
)
|
||||
|
||||
qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识')
|
||||
tunnel_id: int = Field(gt=0, description='隧道ID')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
|
||||
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
|
||||
|
||||
_normalize_reason = field_validator('fifo_skip_reason', mode='before')(_normalize_fifo_reason)
|
||||
|
||||
|
||||
class TunnelEntrySuccessResponseModel(BaseModel):
|
||||
"""托盘扫码入隧成功响应模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
tunnel_id: int = Field(gt=0, description='隧道ID')
|
||||
trace_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='物料溯源号')
|
||||
|
||||
|
||||
class UsageSuccessResponseModel(BaseModel):
|
||||
@@ -244,6 +288,8 @@ class FillingModel(BaseModel):
|
||||
strain_code_snapshot: str = Field(min_length=1, max_length=64, pattern=r'\S', description='菌种编码快照')
|
||||
strain_name_snapshot: str = Field(min_length=1, max_length=100, pattern=r'\S', description='菌种名称快照')
|
||||
batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次')
|
||||
production_date: date | None = Field(default=None, description='生产日期;历史未登记时为空')
|
||||
expiry_date: date | None = Field(default=None, description='有效期至;历史未登记时为空')
|
||||
package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格')
|
||||
initial_packages: int = Field(strict=True, gt=0, description='初始包数')
|
||||
active_slot: Literal['ACTIVE'] | None = Field(default=None, description='活动装填槽')
|
||||
@@ -293,6 +339,9 @@ class ShipmentModel(BaseModel):
|
||||
confirm_request_hash: str | None = Field(default=None, max_length=64, description='确认请求摘要')
|
||||
confirmed_by: int | None = Field(default=None, gt=0, description='确认人ID')
|
||||
confirmed_at: datetime | None = Field(default=None, description='确认时间')
|
||||
receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = Field(default=None, description='收货核对状态')
|
||||
received_by: int | None = Field(default=None, gt=0, description='收货核对人')
|
||||
received_at: datetime | None = Field(default=None, description='实际收货核对时间')
|
||||
create_by: str | None = Field(default=None, max_length=64, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
update_by: str | None = Field(default=None, max_length=64, description='更新者')
|
||||
@@ -311,13 +360,17 @@ class UsageModel(BaseModel):
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
active_filling_id: int = Field(gt=0, description='活动装填记录ID')
|
||||
site_id: int = Field(gt=0, description='现场ID')
|
||||
line_id: int = Field(gt=0, description='产线ID')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='物料溯源号')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='生产批次快照')
|
||||
line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)')
|
||||
used_packages: int = Field(strict=True, gt=0, description='本次使用包数')
|
||||
remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
|
||||
request_hash: str = Field(min_length=1, max_length=64, pattern=r'\S', description='请求摘要')
|
||||
operator_id: int = Field(gt=0, description='操作人ID')
|
||||
occurred_at: datetime = Field(description='发生时间')
|
||||
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
|
||||
|
||||
|
||||
class ReturnItemModel(BaseModel):
|
||||
@@ -329,6 +382,9 @@ class ReturnItemModel(BaseModel):
|
||||
return_id: int = Field(gt=0, description='退回单ID')
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='申请时活动装填记录快照')
|
||||
tunnel_id_snapshot: int | None = Field(default=None, gt=0, description='申请时隧道ID快照')
|
||||
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='申请时剩余包数快照')
|
||||
create_by: str | None = Field(default=None, max_length=64, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
@@ -420,6 +476,8 @@ class UsagePageQueryModel(BaseModel):
|
||||
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='使用记录号')
|
||||
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='物料溯源号')
|
||||
line_id: int | None = Field(default=None, gt=0, description='产线ID')
|
||||
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间')
|
||||
@@ -496,15 +554,20 @@ class PalletScanResponseModel(BaseModel):
|
||||
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
qr_token: str | None = Field(default=None, min_length=1, max_length=128, description='解析后的固定二维码标识,写入时使用')
|
||||
flow_state: PalletFlowState = Field(description='托盘流转状态')
|
||||
warehouse_id: int | None = Field(default=None, gt=0, description='当前仓库ID')
|
||||
site_id: int | None = Field(default=None, gt=0, description='当前现场ID')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='当前隧道ID')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='当前物料溯源号')
|
||||
current_line_id: int | None = Field(default=None, gt=0, description='当前绑定产线ID')
|
||||
active_filling_id: int | None = Field(default=None, gt=0, description='当前活动装填记录ID')
|
||||
strain_id: int | None = Field(default=None, 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 | None = Field(default=None, 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='包装规格')
|
||||
capacity_packages: int = Field(strict=True, gt=0, description='容量包数')
|
||||
remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus
|
||||
@@ -28,7 +28,18 @@ def _validate_report_query_date_range(value: BaseModel) -> BaseModel:
|
||||
return value
|
||||
|
||||
|
||||
class PalletInventoryReportQueryModel(BaseModel):
|
||||
class TunnelReportQueryModel(BaseModel):
|
||||
"""隧道报表共享筛选;编号/名称可直接输入,溯源号按完整值匹配。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
|
||||
tunnel_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, min_length=1, max_length=100, pattern=r'\S', description='隧道名称')
|
||||
trace_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='内部溯源号(精确匹配)')
|
||||
|
||||
|
||||
class PalletInventoryReportQueryModel(TunnelReportQueryModel):
|
||||
"""托盘库存报表查询模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
@@ -69,6 +80,12 @@ class PalletInventoryReportModel(BaseModel):
|
||||
initial_packages: int | None = Field(default=None, strict=True, gt=0, description='初始包数')
|
||||
remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数')
|
||||
updated_at: datetime = Field(description='库存更新时间')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='当前隧道ID')
|
||||
tunnel_code: str | None = Field(default=None, max_length=64, description='当前隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='当前隧道名称')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='当前内部溯源号')
|
||||
production_date: date | None = Field(default=None, description='当前物料生产日期,历史未登记为空')
|
||||
expiry_date: date | None = Field(default=None, description='当前物料有效期至,历史未登记为空')
|
||||
|
||||
|
||||
class FillingReportQueryModel(BaseModel):
|
||||
@@ -148,8 +165,8 @@ class ShipmentReportModel(BaseModel):
|
||||
package_qty: int = Field(strict=True, gt=0, description='发货时包数快照')
|
||||
|
||||
|
||||
class ConsumptionReportQueryModel(BaseModel):
|
||||
"""产线消耗报表查询模型。"""
|
||||
class ConsumptionReportQueryModel(TunnelReportQueryModel):
|
||||
"""隧道消耗报表查询模型,兼容历史产线筛选。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
@@ -168,7 +185,7 @@ class ConsumptionReportQueryModel(BaseModel):
|
||||
|
||||
|
||||
class ConsumptionReportModel(BaseModel):
|
||||
"""产线消耗报表行模型。"""
|
||||
"""隧道消耗报表行模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
|
||||
@@ -179,7 +196,7 @@ class ConsumptionReportModel(BaseModel):
|
||||
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
|
||||
site_id: int = Field(gt=0, description='现场ID')
|
||||
site_name: str | None = Field(default=None, max_length=100, description='现场名称')
|
||||
line_id: int = Field(gt=0, description='产线ID')
|
||||
line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)')
|
||||
line_name: str | None = Field(default=None, max_length=100, description='产线名称')
|
||||
pallet_id: int = Field(gt=0, description='托盘ID')
|
||||
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
@@ -191,9 +208,16 @@ class ConsumptionReportModel(BaseModel):
|
||||
remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数')
|
||||
operator_id: int = Field(gt=0, description='操作人ID')
|
||||
operator_name: str | None = Field(default=None, max_length=100, description='操作人名称')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='使用时隧道ID')
|
||||
tunnel_code: str | None = Field(default=None, max_length=64, description='使用时隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='使用时隧道名称')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='使用时内部溯源号')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='使用时生产批次快照')
|
||||
production_date: date | None = Field(default=None, description='使用时原装填物料生产日期')
|
||||
expiry_date: date | None = Field(default=None, description='使用时原装填物料有效期至')
|
||||
|
||||
|
||||
class ReturnReportQueryModel(BaseModel):
|
||||
class ReturnReportQueryModel(TunnelReportQueryModel):
|
||||
"""退回仓库报表查询模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
@@ -207,6 +231,7 @@ class ReturnReportQueryModel(BaseModel):
|
||||
target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID')
|
||||
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
status: ReturnStatus | None = Field(default=None, description='退回状态')
|
||||
batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回生产批次')
|
||||
|
||||
_validate_date_range = model_validator(mode='after')(_validate_report_query_date_range)
|
||||
|
||||
@@ -229,9 +254,17 @@ class ReturnReportModel(BaseModel):
|
||||
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='活动装填记录快照')
|
||||
remaining_packages_snapshot: int = Field(strict=True, ge=0, description='剩余包数快照')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='退回时隧道ID快照')
|
||||
tunnel_code: str | None = Field(default=None, max_length=64, description='退回隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='退回隧道名称')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='退回时内部溯源号快照')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='退回时生产批次快照')
|
||||
batch_no: str | None = Field(default=None, max_length=64, description='生产批次,历史记录回查原装填记录')
|
||||
production_date: date | None = Field(default=None, description='退回时原装填物料生产日期')
|
||||
expiry_date: date | None = Field(default=None, description='退回时原装填物料有效期至')
|
||||
|
||||
|
||||
class PalletTraceReportQueryModel(BaseModel):
|
||||
class PalletTraceReportQueryModel(TunnelReportQueryModel):
|
||||
"""托盘追溯报表查询模型。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
|
||||
@@ -242,6 +275,7 @@ class PalletTraceReportQueryModel(BaseModel):
|
||||
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间')
|
||||
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
|
||||
event_type: PalletEventType | None = Field(default=None, description='事件类型')
|
||||
batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='事件生产批次快照')
|
||||
|
||||
_validate_date_range = model_validator(mode='after')(_validate_report_query_date_range)
|
||||
|
||||
@@ -277,3 +311,29 @@ class PalletTraceReportModel(BaseModel):
|
||||
operator_id: int = Field(gt=0, description='操作人ID')
|
||||
operator_name: str | None = Field(default=None, max_length=100, description='操作人名称')
|
||||
occurred_at: datetime = Field(description='发生时间')
|
||||
tunnel_id: int | None = Field(default=None, gt=0, description='事件隧道ID,优先变更后隧道')
|
||||
tunnel_code: str | None = Field(default=None, max_length=64, description='事件隧道编号')
|
||||
tunnel_name: str | None = Field(default=None, max_length=100, description='事件隧道名称')
|
||||
trace_no: str | None = Field(default=None, max_length=64, description='事件内部溯源号')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='事件生产批次快照')
|
||||
before_tunnel_id: int | None = Field(default=None, gt=0, description='变更前隧道ID')
|
||||
before_tunnel_code: str | None = Field(default=None, max_length=64, description='变更前隧道编号')
|
||||
before_tunnel_name: str | None = Field(default=None, max_length=100, description='变更前隧道名称')
|
||||
after_tunnel_id: int | None = Field(default=None, gt=0, description='变更后隧道ID')
|
||||
after_tunnel_code: str | None = Field(default=None, max_length=64, description='变更后隧道编号')
|
||||
after_tunnel_name: str | None = Field(default=None, max_length=100, description='变更后隧道名称')
|
||||
production_date: date | None = Field(default=None, description='事件原装填物料生产日期')
|
||||
expiry_date: date | None = Field(default=None, description='事件原装填物料有效期至')
|
||||
|
||||
@computed_field(description='本事件实际使用包数,仅使用事件计入')
|
||||
@property
|
||||
def used_packages(self) -> int:
|
||||
return (
|
||||
self.before_remaining_packages - self.after_remaining_packages
|
||||
if self.event_type == PalletEventType.USAGE_RECORDED else 0
|
||||
)
|
||||
|
||||
@computed_field(description='本事件确认退回包数,退回申请不重复计入')
|
||||
@property
|
||||
def returned_packages(self) -> int:
|
||||
return self.before_remaining_packages if self.event_type == PalletEventType.RETURN_CONFIRMED else 0
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
from module_business.domain.enums import DocumentStatus
|
||||
|
||||
|
||||
class ShipmentLabelModel(BaseModel):
|
||||
"""独立打印载荷,二维码由发货查询授权后生成,无需基础资料权限。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
shipment_no: str
|
||||
shipment_status: DocumentStatus
|
||||
pallet_code: str
|
||||
customer_name: str
|
||||
site_name: str
|
||||
strain_name: str
|
||||
batch_no: str
|
||||
production_date: date | None = None
|
||||
expiry_date: date | None = None
|
||||
package_qty: int = Field(strict=True, gt=0)
|
||||
qr_code_data_url: str
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
from module_business.entity.vo.operation_vo import PositiveId, ShipmentItemModel, ShipmentModel, ShipmentPageQueryModel
|
||||
|
||||
|
||||
class ShipmentReceiptCommandModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra='forbid')
|
||||
|
||||
scanned_pallet_ids: list[PositiveId] = Field(min_length=1, description='实际核对的托盘集合')
|
||||
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S')
|
||||
|
||||
@field_validator('scanned_pallet_ids')
|
||||
@classmethod
|
||||
def unique_pallets(cls, value: list[int]) -> list[int]:
|
||||
if len(value) != len(set(value)):
|
||||
raise ValueError('核对托盘不能重复')
|
||||
return value
|
||||
|
||||
|
||||
class ShipmentReceiptQueryModel(ShipmentPageQueryModel):
|
||||
receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = None
|
||||
|
||||
|
||||
class ShipmentReceiptItemModel(ShipmentItemModel):
|
||||
pallet_code: str = Field(description='原托盘号')
|
||||
qr_token: str = Field(description='规范二维码标识')
|
||||
strain_name: str | None = None
|
||||
batch_no: str | None = None
|
||||
production_date: date | None = None
|
||||
expiry_date: date | None = None
|
||||
|
||||
|
||||
class ShipmentReceiptModel(ShipmentModel):
|
||||
items: list[ShipmentReceiptItemModel] = Field(default_factory=list)
|
||||
@@ -88,6 +88,7 @@ AdoptionRootType = Literal[
|
||||
'customer',
|
||||
'site',
|
||||
'line',
|
||||
'tunnel',
|
||||
'warehouse',
|
||||
'pallet',
|
||||
'inbound',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class UsageFifoCandidateModel(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
|
||||
|
||||
|
||||
class UsageFifoRecommendationModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
recommended: UsageFifoCandidateModel | None = None
|
||||
unknown_arrival_count: int = 0
|
||||
requires_skip_reason: bool = False
|
||||
message: str
|
||||
@@ -63,6 +63,8 @@ class FillingService:
|
||||
'warehouse_id': page_object.warehouse_id,
|
||||
'strain_id': page_object.strain_id,
|
||||
'batch_no': page_object.batch_no.strip(),
|
||||
'production_date': page_object.production_date.isoformat(),
|
||||
'expiry_date': page_object.expiry_date.isoformat(),
|
||||
'package_spec': page_object.package_spec.strip(),
|
||||
'initial_packages': page_object.initial_packages,
|
||||
},
|
||||
@@ -147,10 +149,11 @@ class FillingService:
|
||||
|
||||
@staticmethod
|
||||
def _validate_package_count(initial_packages: int, pallet: BizPallet) -> None:
|
||||
if type(initial_packages) is not int or initial_packages <= 0:
|
||||
raise ServiceException(message='装填包数必须为正整数')
|
||||
if initial_packages > pallet.capacity_packages:
|
||||
raise ServiceException(message='装填包数不能超过托盘容量')
|
||||
standard_packages = 100
|
||||
if type(initial_packages) is not int or initial_packages != standard_packages:
|
||||
raise ServiceException(message='新装填包数固定为 100 包')
|
||||
if pallet.capacity_packages != standard_packages:
|
||||
raise ServiceException(message='历史非 100 包托盘不允许新增装填')
|
||||
|
||||
@staticmethod
|
||||
def _validate_active_strain(strain: BizStrain | None) -> BizStrain:
|
||||
@@ -276,10 +279,11 @@ class FillingService:
|
||||
pallet,
|
||||
inventory,
|
||||
inventory.warehouse_id,
|
||||
qr_token,
|
||||
pallet.qr_token,
|
||||
)
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState.EMPTY_IN_WAREHOUSE,
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
@@ -472,6 +476,8 @@ class FillingService:
|
||||
strain_code_snapshot=strain.strain_code,
|
||||
strain_name_snapshot=strain.strain_name,
|
||||
batch_no=page_object.batch_no.strip(),
|
||||
production_date=page_object.production_date,
|
||||
expiry_date=page_object.expiry_date,
|
||||
package_spec=page_object.package_spec.strip(),
|
||||
initial_packages=page_object.initial_packages,
|
||||
active_slot='ACTIVE',
|
||||
|
||||
@@ -349,6 +349,7 @@ class InboundService:
|
||||
cls._validate_pallet_pair(pallet, inventory)
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState.UNSTOCKED,
|
||||
warehouse_id=None,
|
||||
|
||||
@@ -16,6 +16,7 @@ from module_business.entity.vo.master_vo import (
|
||||
DeleteLineModel,
|
||||
DeleteSiteModel,
|
||||
DeleteStrainModel,
|
||||
DeleteTunnelModel,
|
||||
DeleteWarehouseModel,
|
||||
LineModel,
|
||||
LinePageQueryModel,
|
||||
@@ -23,12 +24,14 @@ from module_business.entity.vo.master_vo import (
|
||||
SitePageQueryModel,
|
||||
StrainModel,
|
||||
StrainPageQueryModel,
|
||||
TunnelModel,
|
||||
TunnelPageQueryModel,
|
||||
WarehouseModel,
|
||||
WarehousePageQueryModel,
|
||||
)
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
|
||||
MasterResource = Literal['strain', 'customer', 'site', 'line', 'warehouse']
|
||||
MasterResource = Literal['strain', 'customer', 'site', 'line', 'tunnel', 'warehouse']
|
||||
|
||||
|
||||
_RESOURCE_CONFIG: dict[MasterResource, dict[str, Any]] = {
|
||||
@@ -60,6 +63,13 @@ _RESOURCE_CONFIG: dict[MasterResource, dict[str, Any]] = {
|
||||
'ids_field': 'line_ids',
|
||||
'vo': LineModel,
|
||||
},
|
||||
'tunnel': {
|
||||
'label': '隧道',
|
||||
'id_field': 'tunnel_id',
|
||||
'code_field': 'tunnel_code',
|
||||
'ids_field': 'tunnel_ids',
|
||||
'vo': TunnelModel,
|
||||
},
|
||||
'warehouse': {
|
||||
'label': '仓库',
|
||||
'id_field': 'warehouse_id',
|
||||
@@ -105,6 +115,13 @@ class MasterService:
|
||||
assert_record_in_scope(site, scope, '目标客户现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='目标客户现场已停用')
|
||||
elif resource == 'tunnel':
|
||||
site = await MasterDao.get_site(db, page_object.site_id, scope)
|
||||
if site is None:
|
||||
raise ServiceException(message='目标客户现场不存在')
|
||||
assert_record_in_scope(site, scope, '目标客户现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='目标客户现场已停用')
|
||||
|
||||
@staticmethod
|
||||
async def _find_duplicate(
|
||||
@@ -124,6 +141,12 @@ class MasterService:
|
||||
page_object.line_code,
|
||||
page_object.site_id,
|
||||
)
|
||||
if resource == 'tunnel':
|
||||
return await MasterDao.find_tunnel_by_code(
|
||||
db,
|
||||
page_object.tunnel_code,
|
||||
page_object.site_id,
|
||||
)
|
||||
code_field = _RESOURCE_CONFIG[resource]['code_field']
|
||||
return await getattr(MasterDao, f'find_{resource}_by_code')(
|
||||
db,
|
||||
@@ -413,6 +436,48 @@ class MasterService:
|
||||
) -> NoReturn:
|
||||
return await cls.reject_physical_delete()
|
||||
|
||||
@classmethod
|
||||
async def list_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
query_object: TunnelPageQueryModel,
|
||||
scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[TunnelModel]:
|
||||
return await cls._list(db, 'tunnel', query_object, scope, is_page)
|
||||
|
||||
@classmethod
|
||||
async def detail_tunnel(cls, db: AsyncSession, tunnel_id: int, scope: TestBatchScope) -> TunnelModel:
|
||||
return await cls._detail(db, 'tunnel', tunnel_id, scope)
|
||||
|
||||
@classmethod
|
||||
async def add_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel: TunnelModel,
|
||||
current_user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
return await cls._add(db, 'tunnel', tunnel, current_user, scope)
|
||||
|
||||
@classmethod
|
||||
async def edit_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
tunnel: TunnelModel,
|
||||
current_user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
return await cls._edit(db, 'tunnel', tunnel, current_user, scope)
|
||||
|
||||
@classmethod
|
||||
async def remove_tunnel(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
value: int | DeleteTunnelModel,
|
||||
) -> NoReturn:
|
||||
return await cls.reject_physical_delete()
|
||||
|
||||
@classmethod
|
||||
async def list_warehouse(
|
||||
cls,
|
||||
|
||||
@@ -116,6 +116,8 @@ class PalletService:
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await cls._prepare_test_write(db, current_user, scope)
|
||||
if pallet.capacity_packages != 100:
|
||||
raise ServiceException(message='新托盘容量固定为 100 包')
|
||||
if await MasterDao.find_pallet_by_code(db, pallet.pallet_code) is not None:
|
||||
raise ServiceException(message='托盘编号已存在')
|
||||
|
||||
@@ -162,7 +164,11 @@ class PalletService:
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await cls._prepare_test_write(db, current_user, scope)
|
||||
_, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope)
|
||||
current_pallet, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope)
|
||||
if current_pallet.capacity_packages == 100 and pallet.capacity_packages != 100:
|
||||
raise ServiceException(message='新规格托盘容量固定为 100 包')
|
||||
if current_pallet.capacity_packages != pallet.capacity_packages:
|
||||
raise ServiceException(message='历史托盘容量不允许修改')
|
||||
if pallet.capacity_packages < inventory.remaining_packages:
|
||||
raise ServiceException(message='托盘容量不能小于当前剩余包数')
|
||||
await MasterDao.update_pallet(
|
||||
|
||||
@@ -19,7 +19,7 @@ from module_business.dao.operation_dao import OperationDao
|
||||
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
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.master_do import BizPallet, BizTunnel
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord,
|
||||
BizPalletEvent,
|
||||
@@ -421,16 +421,21 @@ class ReturnService:
|
||||
) -> PalletScanResponseModel:
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState(inventory.flow_state),
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
site_id=inventory.site_id,
|
||||
tunnel_id=inventory.tunnel_id,
|
||||
trace_no=inventory.trace_no,
|
||||
current_line_id=inventory.current_line_id,
|
||||
active_filling_id=inventory.active_filling_id,
|
||||
strain_id=filling.strain_id if filling is not None else None,
|
||||
strain_code=filling.strain_code_snapshot if filling is not None else None,
|
||||
strain_name=filling.strain_name_snapshot if filling is not None else None,
|
||||
batch_no=filling.batch_no if filling is not None else None,
|
||||
production_date=filling.production_date if filling is not None else None,
|
||||
expiry_date=filling.expiry_date if filling is not None else None,
|
||||
package_spec=filling.package_spec if filling is not None else None,
|
||||
capacity_packages=pallet.capacity_packages,
|
||||
remaining_packages=inventory.remaining_packages,
|
||||
@@ -543,12 +548,22 @@ class ReturnService:
|
||||
user_name: str,
|
||||
current_time: datetime,
|
||||
test_batch_id: int | None,
|
||||
filling_by_id: dict[int, BizFillingRecord | None] | None = None,
|
||||
) -> list[BizReturnItem]:
|
||||
return [
|
||||
BizReturnItem(
|
||||
return_id=return_id,
|
||||
pallet_id=inventory.pallet_id,
|
||||
active_filling_id_snapshot=inventory.active_filling_id,
|
||||
tunnel_id_snapshot=inventory.tunnel_id,
|
||||
trace_no_snapshot=inventory.trace_no,
|
||||
batch_no_snapshot=(
|
||||
filling_by_id.get(inventory.active_filling_id).batch_no
|
||||
if filling_by_id
|
||||
and inventory.active_filling_id is not None
|
||||
and filling_by_id.get(inventory.active_filling_id) is not None
|
||||
else None
|
||||
),
|
||||
remaining_packages_snapshot=inventory.remaining_packages,
|
||||
create_by=user_name,
|
||||
create_time=current_time,
|
||||
@@ -788,15 +803,23 @@ class ReturnService:
|
||||
)
|
||||
return_order = await OperationDao.add_return_order(query_db, return_order)
|
||||
return_id = int(return_order.return_id)
|
||||
await OperationDao.add_return_items(
|
||||
query_db,
|
||||
cls._build_return_items(return_id, inventories, user_name, current_time, test_scope.batch_id),
|
||||
return_items = cls._build_return_items(
|
||||
return_id,
|
||||
inventories,
|
||||
user_name,
|
||||
current_time,
|
||||
test_scope.batch_id,
|
||||
filling_by_id,
|
||||
)
|
||||
await OperationDao.add_return_items(query_db, return_items)
|
||||
return_order.items = return_items
|
||||
|
||||
for inventory in inventories:
|
||||
before_state = inventory.flow_state
|
||||
before_warehouse_id = inventory.warehouse_id
|
||||
before_site_id = inventory.site_id
|
||||
before_tunnel_id = inventory.tunnel_id
|
||||
before_trace_no = inventory.trace_no
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
inventory.flow_state = PalletFlowState.RETURNING.value
|
||||
@@ -811,12 +834,23 @@ class ReturnService:
|
||||
after_state=PalletFlowState.RETURNING.value,
|
||||
before_warehouse_id=before_warehouse_id,
|
||||
before_site_id=before_site_id,
|
||||
before_tunnel_id=before_tunnel_id,
|
||||
before_line_id=before_line_id,
|
||||
after_warehouse_id=None,
|
||||
after_site_id=inventory.site_id,
|
||||
after_tunnel_id=inventory.tunnel_id,
|
||||
after_line_id=inventory.current_line_id,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=inventory.remaining_packages,
|
||||
trace_no=before_trace_no,
|
||||
batch_no_snapshot=next(
|
||||
(
|
||||
item.batch_no_snapshot
|
||||
for item in getattr(return_order, 'items', [])
|
||||
if item.pallet_id == inventory.pallet_id
|
||||
),
|
||||
None,
|
||||
),
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
@@ -860,6 +894,8 @@ class ReturnService:
|
||||
if (
|
||||
inventory.remaining_packages != item.remaining_packages_snapshot
|
||||
or inventory.active_filling_id != item.active_filling_id_snapshot
|
||||
or inventory.tunnel_id != item.tunnel_id_snapshot
|
||||
or inventory.trace_no != item.trace_no_snapshot
|
||||
):
|
||||
raise ServiceException(message='托盘库存与退回申请快照不一致')
|
||||
if (
|
||||
@@ -881,7 +917,7 @@ class ReturnService:
|
||||
return_order: BizReturnOrder,
|
||||
items: list[BizReturnItem],
|
||||
test_scope: TestBatchScope,
|
||||
) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord]]:
|
||||
) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord], dict[int, BizTunnel]]:
|
||||
ordered_ids = sorted(item.pallet_id for item in items)
|
||||
assert_record_in_scope(return_order, test_scope, '退回单')
|
||||
for item in items:
|
||||
@@ -911,6 +947,21 @@ class ReturnService:
|
||||
inventory_by_id[pallet_id],
|
||||
)
|
||||
|
||||
tunnel_by_id: dict[int, BizTunnel] = {}
|
||||
tunnel_ids = sorted(
|
||||
{
|
||||
item.tunnel_id_snapshot
|
||||
for item in items
|
||||
if item.tunnel_id_snapshot is not None
|
||||
}
|
||||
)
|
||||
for tunnel_id in tunnel_ids:
|
||||
tunnel = await MasterDao.get_tunnel(query_db, tunnel_id, test_scope, for_update=True)
|
||||
if tunnel is None:
|
||||
raise ServiceException(message='退回单关联隧道不存在')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
tunnel_by_id[tunnel_id] = tunnel
|
||||
|
||||
filling_by_id = {}
|
||||
filling_ids = sorted(
|
||||
{
|
||||
@@ -928,7 +979,11 @@ class ReturnService:
|
||||
for inventory in ordered_inventories:
|
||||
if inventory.active_filling_id is not None:
|
||||
cls._validate_active_filling(filling_by_id[inventory.active_filling_id], inventory)
|
||||
return ordered_inventories, filling_by_id
|
||||
item = item_by_id[inventory.pallet_id]
|
||||
filling = filling_by_id[inventory.active_filling_id]
|
||||
if item.batch_no_snapshot is not None and filling.batch_no != item.batch_no_snapshot:
|
||||
raise ServiceException(message='托盘生产批次与退回申请快照不一致')
|
||||
return ordered_inventories, filling_by_id, tunnel_by_id
|
||||
|
||||
@classmethod
|
||||
async def _lock_and_reauthorize_confirm_warehouse(
|
||||
@@ -1121,7 +1176,7 @@ class ReturnService:
|
||||
if scanned_ids != sorted(item_ids):
|
||||
raise ServiceException(message='确认扫码托盘集合与退回单明细不一致')
|
||||
|
||||
inventories, _ = await cls._lock_confirm_pallets(query_db, locked, items, test_scope)
|
||||
inventories, _, tunnels = await cls._lock_confirm_pallets(query_db, locked, items, test_scope)
|
||||
await cls._lock_and_reauthorize_confirm_warehouse(
|
||||
query_db,
|
||||
locked,
|
||||
@@ -1151,6 +1206,8 @@ class ReturnService:
|
||||
before_state = inventory.flow_state
|
||||
before_warehouse_id = inventory.warehouse_id
|
||||
before_site_id = inventory.site_id
|
||||
before_tunnel_id = inventory.tunnel_id
|
||||
before_trace_no = inventory.trace_no
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
if inventory.remaining_packages > 0:
|
||||
@@ -1162,6 +1219,9 @@ class ReturnService:
|
||||
inventory.flow_state = target_state
|
||||
inventory.warehouse_id = locked.target_warehouse_id
|
||||
inventory.site_id = None
|
||||
inventory.site_arrived_at = None
|
||||
inventory.tunnel_id = None
|
||||
inventory.trace_no = None
|
||||
inventory.current_line_id = None
|
||||
inventory.version += 1
|
||||
inventory.updated_at = current_time
|
||||
@@ -1176,10 +1236,21 @@ class ReturnService:
|
||||
before_site_id=before_site_id,
|
||||
before_line_id=before_line_id,
|
||||
after_warehouse_id=locked.target_warehouse_id,
|
||||
before_tunnel_id=before_tunnel_id,
|
||||
after_site_id=None,
|
||||
after_tunnel_id=None,
|
||||
after_line_id=None,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=inventory.remaining_packages,
|
||||
trace_no=before_trace_no,
|
||||
batch_no_snapshot=next(
|
||||
(
|
||||
item.batch_no_snapshot
|
||||
for item in items
|
||||
if item.pallet_id == inventory.pallet_id
|
||||
),
|
||||
None,
|
||||
),
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
@@ -1188,6 +1259,27 @@ class ReturnService:
|
||||
)
|
||||
await InventoryDao.add_event(query_db, inventory, event, test_scope)
|
||||
|
||||
for tunnel_id, tunnel in tunnels.items():
|
||||
trace_nos = {
|
||||
item.trace_no_snapshot
|
||||
for item in items
|
||||
if item.tunnel_id_snapshot == tunnel_id and item.trace_no_snapshot
|
||||
}
|
||||
if (
|
||||
tunnel.current_trace_no in trace_nos
|
||||
and await MasterDao.count_tunnel_trace_members(
|
||||
query_db,
|
||||
tunnel_id,
|
||||
tunnel.current_trace_no,
|
||||
test_scope,
|
||||
)
|
||||
== 0
|
||||
):
|
||||
tunnel.current_trace_no = None
|
||||
tunnel.current_trace_started_at = None
|
||||
tunnel.update_by = user_name
|
||||
tunnel.update_time = current_time
|
||||
|
||||
await query_db.commit()
|
||||
return cls._success('确认成功', return_id)
|
||||
except IntegrityError:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import base64
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from exceptions.exception import ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
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.domain.enums import DocumentStatus
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
|
||||
from module_business.service.pallet_service import PalletService
|
||||
from module_business.service.shipment_service import ShipmentService
|
||||
|
||||
|
||||
class ShipmentLabelService:
|
||||
"""复用发货双数据范围读取原始物料资料,不改变托盘、批号或库存。"""
|
||||
|
||||
@staticmethod
|
||||
async def get_labels_services(
|
||||
query_db: AsyncSession,
|
||||
shipment_id: int,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> list[ShipmentLabelModel]:
|
||||
warehouse_ids, site_ids = await ShipmentService._scopes(query_db, current_user)
|
||||
shipment = await OperationDao.get_shipment_by_id(
|
||||
query_db, shipment_id, test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids,
|
||||
)
|
||||
if shipment is None:
|
||||
ShipmentService._raise_shipment_not_accessible(current_user)
|
||||
assert_record_in_scope(shipment, test_scope, '发货单')
|
||||
if shipment.status not in (DocumentStatus.DRAFT.value, DocumentStatus.CONFIRMED.value):
|
||||
raise ServiceException(message='当前发货单状态不支持打印标签')
|
||||
if not shipment.items:
|
||||
raise ServiceException(message='发货单没有可打印的托盘')
|
||||
for item in shipment.items:
|
||||
assert_record_in_scope(item, test_scope, '发货明细')
|
||||
|
||||
# 先授权单据,再读取其目标现场与所属客户,避免通过标签枚举跨范围资料。
|
||||
site = await MasterDao.get_site(query_db, shipment.target_site_id, test_scope)
|
||||
assert_record_in_scope(site, test_scope, '发货现场')
|
||||
customer = await MasterDao.get_customer(query_db, site.customer_id, test_scope)
|
||||
assert_record_in_scope(customer, test_scope, '发货客户')
|
||||
rows = await ShipmentLabelDao.get_label_sources(query_db, shipment, test_scope)
|
||||
if (
|
||||
len(rows) != len(shipment.items)
|
||||
or {row[0].shipment_item_id for row in rows} != {item.shipment_item_id for item in shipment.items}
|
||||
):
|
||||
raise ServiceException(message='发货明细已变化,请刷新后重新打印')
|
||||
|
||||
draft = shipment.status == DocumentStatus.DRAFT.value
|
||||
# 整单全部验证后才生成任何二维码,缺少关联资料时不得输出部分标签。
|
||||
for item, pallet, filling, quantity in rows:
|
||||
for record, name in ((item, '发货明细'), (pallet, '托盘'), (filling, '装填记录')):
|
||||
assert_record_in_scope(record, test_scope, name)
|
||||
if (
|
||||
item.shipment_id != shipment.shipment_id
|
||||
or item.pallet_id != pallet.pallet_id
|
||||
or filling.pallet_id != pallet.pallet_id
|
||||
or type(quantity) is not int
|
||||
or quantity <= 0
|
||||
or not pallet.qr_token
|
||||
):
|
||||
raise ServiceException(message='发货标签资料不完整,请核对托盘与装填记录')
|
||||
if draft:
|
||||
if pallet.asset_status != '0' or filling.active_slot != 'ACTIVE' or quantity > pallet.capacity_packages:
|
||||
raise ServiceException(message='草稿托盘已不满足发货条件,请刷新后重新打印')
|
||||
elif item.active_filling_id != filling.filling_id or quantity != item.package_qty:
|
||||
raise ServiceException(message='发货快照不完整,无法打印标签')
|
||||
|
||||
return [
|
||||
ShipmentLabelModel(
|
||||
shipment_no=shipment.shipment_no,
|
||||
shipment_status=shipment.status,
|
||||
pallet_code=pallet.pallet_code,
|
||||
customer_name=customer.customer_name,
|
||||
site_name=site.site_name,
|
||||
strain_name=filling.strain_name_snapshot,
|
||||
batch_no=filling.batch_no,
|
||||
production_date=filling.production_date,
|
||||
expiry_date=filling.expiry_date,
|
||||
package_qty=quantity,
|
||||
qr_code_data_url='data:image/png;base64,' + base64.b64encode(
|
||||
PalletService.render_qr_png(pallet.qr_token),
|
||||
).decode('ascii'),
|
||||
)
|
||||
for item, pallet, filling, quantity in rows
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""接收方按原发货单核对收货;不重新编托盘号、批号或装填记录。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import NoReturn
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.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_receipt_dao import ShipmentReceiptDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory, BizShipmentItem, BizShipmentOrder
|
||||
from module_business.entity.vo.operation_vo import ShipmentModel
|
||||
from module_business.entity.vo.shipment_receipt_vo import (
|
||||
ShipmentReceiptCommandModel,
|
||||
ShipmentReceiptItemModel,
|
||||
ShipmentReceiptModel,
|
||||
ShipmentReceiptQueryModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
|
||||
|
||||
class ShipmentReceiptService:
|
||||
@staticmethod
|
||||
async def _site_scope(db: AsyncSession, user: CurrentUserModel) -> list[int] | None:
|
||||
if user.user is None or user.user.user_id is None:
|
||||
raise PermissionException(data='', message='当前用户信息无效')
|
||||
if user.user.admin:
|
||||
return None
|
||||
return sorted(set(await AccessDao.list_user_site_ids(db, user.user.user_id)))
|
||||
|
||||
@staticmethod
|
||||
def _missing() -> NoReturn:
|
||||
raise PermissionException(data='', message='无权访问该收货单或单据不存在')
|
||||
|
||||
@classmethod
|
||||
async def list_services(
|
||||
cls, db: AsyncSession, query: ShipmentReceiptQueryModel, user: CurrentUserModel, scope: TestBatchScope
|
||||
) -> PageModel:
|
||||
site_ids = await cls._site_scope(db, user)
|
||||
if query.target_site_id and site_ids is not None and query.target_site_id not in site_ids:
|
||||
cls._missing()
|
||||
result = await ShipmentReceiptDao.list_orders(db, query, scope, site_ids)
|
||||
return result.model_copy(update={'rows': [ShipmentModel.model_validate(row) for row in result.rows]})
|
||||
|
||||
@classmethod
|
||||
async def _order(
|
||||
cls, db: AsyncSession, shipment_id: int, user: CurrentUserModel, scope: TestBatchScope
|
||||
) -> BizShipmentOrder:
|
||||
site_ids = await cls._site_scope(db, user)
|
||||
order = await OperationDao.get_shipment_by_id(db, shipment_id, scope, warehouse_ids=None, site_ids=site_ids)
|
||||
if order is None:
|
||||
cls._missing()
|
||||
assert_record_in_scope(order, scope, '发货单')
|
||||
await AccessService.assert_site_access(db, user, order.target_site_id)
|
||||
if order.status != 'CONFIRMED':
|
||||
raise ServiceException(message='发货草稿尚未出库,不能核对收货')
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
async def _item_model(db: AsyncSession, item: BizShipmentItem, scope: TestBatchScope) -> ShipmentReceiptItemModel:
|
||||
assert_record_in_scope(item, scope, '发货明细')
|
||||
pallet = await MasterDao.get_pallet(db, item.pallet_id, scope)
|
||||
if pallet is None or item.active_filling_id is None or item.package_qty is None:
|
||||
raise ServiceException(message='发货物料快照不完整')
|
||||
assert_record_in_scope(pallet, scope, '托盘')
|
||||
filling = await OperationDao.get_filling_by_id(db, item.active_filling_id, scope)
|
||||
if filling is None:
|
||||
raise ServiceException(message='原发货装填记录不存在')
|
||||
assert_record_in_scope(filling, scope, '装填记录')
|
||||
if filling.pallet_id != item.pallet_id:
|
||||
raise ServiceException(message='原发货托盘与批次不一致')
|
||||
return ShipmentReceiptItemModel.model_validate(
|
||||
{
|
||||
'shipment_item_id': item.shipment_item_id,
|
||||
'shipment_id': item.shipment_id,
|
||||
'pallet_id': item.pallet_id,
|
||||
'active_filling_id': item.active_filling_id,
|
||||
'package_qty': item.package_qty,
|
||||
'pallet_code': pallet.pallet_code,
|
||||
'qr_token': pallet.qr_token,
|
||||
'strain_name': filling.strain_name_snapshot,
|
||||
'batch_no': filling.batch_no,
|
||||
'production_date': filling.production_date,
|
||||
'expiry_date': filling.expiry_date,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def detail_services(
|
||||
cls, db: AsyncSession, shipment_id: int, user: CurrentUserModel, scope: TestBatchScope
|
||||
) -> ShipmentReceiptModel:
|
||||
order = await cls._order(db, shipment_id, user, scope)
|
||||
data = ShipmentModel.model_validate(order).model_dump()
|
||||
data['items'] = [await cls._item_model(db, item, scope) for item in order.items]
|
||||
return ShipmentReceiptModel.model_validate(data)
|
||||
|
||||
@staticmethod
|
||||
def _validate_inventory(
|
||||
order: BizShipmentOrder,
|
||||
item: BizShipmentItem,
|
||||
inventory: BizPalletInventory,
|
||||
pallet: BizPallet,
|
||||
scope: TestBatchScope,
|
||||
) -> None:
|
||||
assert_record_in_scope(item, scope, '发货明细')
|
||||
assert_record_in_scope(inventory, scope, '库存')
|
||||
assert_record_in_scope(pallet, scope, '托盘')
|
||||
if (
|
||||
item.shipment_id != order.shipment_id
|
||||
or item.pallet_id != pallet.pallet_id
|
||||
or inventory.pallet_id != pallet.pallet_id
|
||||
or pallet.asset_status != '0'
|
||||
or inventory.flow_state != PalletFlowState.IN_TRANSIT.value
|
||||
or inventory.site_id != order.target_site_id
|
||||
or inventory.warehouse_id is not None
|
||||
or inventory.tunnel_id is not None
|
||||
or inventory.trace_no is not None
|
||||
or inventory.current_line_id is not None
|
||||
or inventory.active_filling_id != item.active_filling_id
|
||||
or item.active_filling_id is None
|
||||
or type(item.package_qty) is not int
|
||||
or item.package_qty <= 0
|
||||
or inventory.remaining_packages != item.package_qty
|
||||
or type(inventory.version) is not int
|
||||
or inventory.version < 0
|
||||
):
|
||||
raise ServiceException(message='待收货托盘状态、批次或包数与原发货单不一致')
|
||||
|
||||
@classmethod
|
||||
async def scan_services(
|
||||
cls, db: AsyncSession, shipment_id: int, identifier: str, user: CurrentUserModel, scope: TestBatchScope
|
||||
) -> ShipmentReceiptItemModel:
|
||||
order = await cls._order(db, shipment_id, user, scope)
|
||||
if order.receipt_state != 'PENDING':
|
||||
raise ServiceException(message='该发货单无需再次核对收货')
|
||||
pallet = await InventoryDao.get_pallet_by_qr_token(db, identifier, scope)
|
||||
if pallet is None:
|
||||
raise ServiceException(message='扫码托盘不属于该发货单')
|
||||
items = [item for item in order.items if item.pallet_id == pallet.pallet_id]
|
||||
if len(items) != 1:
|
||||
raise ServiceException(message='扫码托盘不属于该发货单')
|
||||
inventory = await MasterDao.get_pallet_inventory(db, pallet.pallet_id, scope)
|
||||
if inventory is None:
|
||||
raise ServiceException(message='托盘库存不存在')
|
||||
cls._validate_inventory(order, items[0], inventory, pallet, scope)
|
||||
return await cls._item_model(db, items[0], scope)
|
||||
|
||||
@staticmethod
|
||||
def _request_hash(shipment_id: int, command: ShipmentReceiptCommandModel) -> str:
|
||||
payload = {'shipment_id': shipment_id, 'scanned_pallet_ids': sorted(command.scanned_pallet_ids)}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
@classmethod
|
||||
async def confirm_services( # noqa: PLR0912, PLR0915
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
shipment_id: int,
|
||||
command: ShipmentReceiptCommandModel,
|
||||
user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await TestBatchAccessService.lock_active_batch(db, scope)
|
||||
site_ids = await cls._site_scope(db, user)
|
||||
order = await OperationDao.get_shipment_for_update(
|
||||
db, shipment_id, scope, warehouse_ids=None, site_ids=site_ids
|
||||
)
|
||||
if order is None:
|
||||
cls._missing()
|
||||
assert_record_in_scope(order, scope, '发货单')
|
||||
# 已有发货查询权限不能替代收货方的目标现场权限;锁后再次核验。
|
||||
await AccessService.assert_site_access(db, user, order.target_site_id, for_update=True)
|
||||
request_hash = cls._request_hash(shipment_id, command)
|
||||
if order.status != 'CONFIRMED':
|
||||
raise ServiceException(message='仅已出库发货单可核对收货')
|
||||
if order.receipt_state == 'RECEIVED':
|
||||
if (
|
||||
order.receipt_idempotency_key != command.idempotency_key
|
||||
or order.receipt_request_hash != request_hash
|
||||
):
|
||||
raise ServiceException(message='发货单已收货,幂等信息不匹配')
|
||||
await db.rollback()
|
||||
return CrudResponseModel(is_success=True, message='收货核对成功', result=shipment_id)
|
||||
if order.receipt_state != 'PENDING':
|
||||
raise ServiceException(message='历史发货数据不重复确认收货')
|
||||
items = list(order.items)
|
||||
expected_ids = sorted(item.pallet_id for item in items)
|
||||
if (
|
||||
not expected_ids
|
||||
or len(expected_ids) != len(set(expected_ids))
|
||||
or expected_ids != sorted(command.scanned_pallet_ids)
|
||||
):
|
||||
raise ServiceException(message='核对托盘集合与原发货明细不一致')
|
||||
inventories = await InventoryDao.get_inventories_for_update(db, expected_ids, scope)
|
||||
pallets = await InventoryDao.get_pallets_by_ids(db, expected_ids, scope)
|
||||
if (
|
||||
sorted(i.pallet_id for i in inventories) != expected_ids
|
||||
or sorted(p.pallet_id for p in pallets) != expected_ids
|
||||
):
|
||||
raise ServiceException(message='待收货托盘或库存缺失')
|
||||
site = await MasterDao.get_site(db, order.target_site_id, scope, for_update=True)
|
||||
if site is None:
|
||||
raise ServiceException(message='收货现场不存在')
|
||||
assert_record_in_scope(site, scope, '收货现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='收货现场已停用')
|
||||
by_inventory = {i.pallet_id: i for i in inventories}
|
||||
by_pallet = {p.pallet_id: p for p in pallets}
|
||||
for item in items:
|
||||
cls._validate_inventory(order, item, by_inventory[item.pallet_id], by_pallet[item.pallet_id], scope)
|
||||
PalletStateMachine.ensure_transition(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED)
|
||||
|
||||
now = datetime.now()
|
||||
order.receipt_state = 'RECEIVED'
|
||||
order.received_at = now
|
||||
order.received_by = user.user.user_id
|
||||
order.receipt_idempotency_key = command.idempotency_key
|
||||
order.receipt_request_hash = request_hash
|
||||
order.update_by = user.user.user_name
|
||||
order.update_time = now
|
||||
for inventory in inventories:
|
||||
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value
|
||||
inventory.site_arrived_at = now
|
||||
inventory.version += 1
|
||||
inventory.updated_at = now
|
||||
event = BizPalletEvent(
|
||||
pallet_id=inventory.pallet_id,
|
||||
event_type=PalletEventType.SHIPMENT_RECEIVED.value,
|
||||
source_type='SHIPMENT',
|
||||
source_id=shipment_id,
|
||||
before_state=PalletFlowState.IN_TRANSIT.value,
|
||||
after_state=inventory.flow_state,
|
||||
before_site_id=inventory.site_id,
|
||||
after_site_id=inventory.site_id,
|
||||
before_remaining_packages=inventory.remaining_packages,
|
||||
after_remaining_packages=inventory.remaining_packages,
|
||||
operator_id=user.user.user_id,
|
||||
occurred_at=now,
|
||||
idempotency_key=command.idempotency_key,
|
||||
request_hash=request_hash,
|
||||
test_batch_id=scope.batch_id,
|
||||
)
|
||||
await InventoryDao.add_event(db, inventory, event, scope)
|
||||
await db.commit()
|
||||
return CrudResponseModel(is_success=True, message='收货核对成功', result=shipment_id)
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise ServiceException(message='收货确认冲突,请刷新核对单后重试') from None
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
@@ -460,6 +460,7 @@ class ShipmentService:
|
||||
)
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState.FILLED_IN_WAREHOUSE,
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
@@ -470,6 +471,8 @@ class ShipmentService:
|
||||
strain_code=filling.strain_code_snapshot,
|
||||
strain_name=filling.strain_name_snapshot,
|
||||
batch_no=filling.batch_no,
|
||||
production_date=filling.production_date,
|
||||
expiry_date=filling.expiry_date,
|
||||
package_spec=filling.package_spec,
|
||||
capacity_packages=pallet.capacity_packages,
|
||||
remaining_packages=inventory.remaining_packages,
|
||||
@@ -902,12 +905,13 @@ class ShipmentService:
|
||||
for inventory in inventories:
|
||||
PalletStateMachine.ensure_transition(
|
||||
PalletFlowState(inventory.flow_state),
|
||||
PalletFlowState.AT_SITE_UNUSED,
|
||||
PalletFlowState.IN_TRANSIT,
|
||||
)
|
||||
|
||||
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
|
||||
current_time = datetime.now()
|
||||
locked_shipment.status = DocumentStatus.CONFIRMED.value
|
||||
locked_shipment.receipt_state = 'PENDING'
|
||||
locked_shipment.confirm_idempotency_key = idempotency_key
|
||||
locked_shipment.confirm_request_hash = request_hash
|
||||
locked_shipment.confirmed_by = user_id
|
||||
@@ -927,7 +931,8 @@ class ShipmentService:
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
|
||||
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value
|
||||
inventory.flow_state = PalletFlowState.IN_TRANSIT.value
|
||||
inventory.site_arrived_at = None
|
||||
inventory.warehouse_id = None
|
||||
inventory.site_id = locked_shipment.target_site_id
|
||||
inventory.current_line_id = None
|
||||
@@ -939,7 +944,7 @@ class ShipmentService:
|
||||
source_type='SHIPMENT',
|
||||
source_id=shipment_id,
|
||||
before_state=before_state,
|
||||
after_state=PalletFlowState.AT_SITE_UNUSED.value,
|
||||
after_state=PalletFlowState.IN_TRANSIT.value,
|
||||
before_warehouse_id=before_warehouse_id,
|
||||
before_site_id=before_site_id,
|
||||
before_line_id=before_line_id,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from exceptions.exception import ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
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.service.usage_service import UsageService
|
||||
|
||||
|
||||
class UsageFifoService:
|
||||
@staticmethod
|
||||
async def recommendation(
|
||||
db: AsyncSession, identifier: str, user: CurrentUserModel, scope: TestBatchScope,
|
||||
*, tunnel_id: int | None = None,
|
||||
) -> UsageFifoRecommendationModel:
|
||||
# 复用扫码入口的现场授权、当前批次与可用状态校验;客户端不指定查询现场。
|
||||
pallet = await UsageService.scan_usage_pallet_services(db, identifier, user, scope)
|
||||
inventory = await MasterDao.get_pallet_inventory(db, pallet.pallet_id, scope)
|
||||
if inventory is None:
|
||||
raise ServiceException(message='托盘库存不存在')
|
||||
assert_record_in_scope(inventory, scope, '库存')
|
||||
target_tunnel_id = pallet.tunnel_id
|
||||
trace_no = pallet.trace_no
|
||||
if tunnel_id is not None:
|
||||
tunnel = await MasterDao.get_tunnel(db, tunnel_id, scope)
|
||||
if tunnel is None or tunnel.site_id != pallet.site_id or tunnel.status != '0':
|
||||
raise ServiceException(message='请选择托盘当前现场的启用隧道')
|
||||
assert_record_in_scope(tunnel, scope, '隧道')
|
||||
if pallet.tunnel_id is not None and pallet.tunnel_id != tunnel_id:
|
||||
raise ServiceException(message='托盘已入其他隧道')
|
||||
target_tunnel_id, trace_no = tunnel_id, tunnel.current_trace_no
|
||||
candidate, unknown = await UsageFifoDao.recommendation(
|
||||
db,
|
||||
site_id=pallet.site_id,
|
||||
strain_id=pallet.strain_id,
|
||||
tunnel_id=target_tunnel_id,
|
||||
trace_no=trace_no,
|
||||
scope=scope,
|
||||
)
|
||||
message = '当前无可确定到货顺序的同品种托盘'
|
||||
if candidate:
|
||||
message = f'先到先用建议:托盘 {candidate["pallet_code"]},批次 {candidate["batch_no"]},余量 {candidate["remaining_packages"]} 包'
|
||||
if unknown:
|
||||
message += f';另有 {unknown} 托盘未登记实际收货时间,请人工核对先后顺序'
|
||||
needs_reason = requires_fifo_skip_reason(pallet.pallet_id, inventory.site_arrived_at, candidate)
|
||||
if needs_reason:
|
||||
message += ';使用当前托盘需填写跳过先到托盘的原因'
|
||||
return UsageFifoRecommendationModel(
|
||||
recommended=candidate, unknown_arrival_count=unknown,
|
||||
requires_skip_reason=needs_reason, message=message,
|
||||
)
|
||||
@@ -16,14 +16,18 @@ from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.usage_fifo_dao import UsageFifoDao
|
||||
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
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.do.operation_do import BizPalletEvent, BizUsageRecord
|
||||
from module_business.entity.vo.master_vo import LinePageQueryModel
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory, BizUsageRecord
|
||||
from module_business.entity.vo.master_vo import LinePageQueryModel, TunnelPageQueryModel
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
TunnelEntryCreateModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
@@ -31,6 +35,7 @@ from module_business.entity.vo.operation_vo import (
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
from module_business.service.usage_transaction import retry_mysql_lock_conflicts
|
||||
|
||||
|
||||
class UsageService:
|
||||
@@ -110,17 +115,52 @@ class UsageService:
|
||||
|
||||
@staticmethod
|
||||
def _build_request_hash(page_object: UsageCreateModel) -> str:
|
||||
fields = {
|
||||
'qr_token': page_object.qr_token.strip(),
|
||||
'tunnel_id': page_object.tunnel_id,
|
||||
'line_id': page_object.line_id,
|
||||
'used_packages': page_object.used_packages,
|
||||
}
|
||||
# 无原因时保留原摘要,迁移前已成功的请求仍可按原键安全重放。
|
||||
if page_object.fifo_skip_reason:
|
||||
fields['fifo_skip_reason'] = page_object.fifo_skip_reason
|
||||
payload = json.dumps(
|
||||
{
|
||||
'qr_token': page_object.qr_token.strip(),
|
||||
'line_id': page_object.line_id,
|
||||
'used_packages': page_object.used_packages,
|
||||
},
|
||||
fields,
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
async def _check_fifo_skip(
|
||||
query_db: AsyncSession,
|
||||
inventory: BizPalletInventory,
|
||||
filling: BizFillingRecord,
|
||||
tunnel_id: int,
|
||||
trace_no: str | None,
|
||||
reason: str | None,
|
||||
test_scope: TestBatchScope,
|
||||
) -> dict[str, Any] | None:
|
||||
if inventory.site_arrived_at is None:
|
||||
return None
|
||||
candidate, _ = await UsageFifoDao.recommendation(
|
||||
query_db, site_id=inventory.site_id, strain_id=filling.strain_id,
|
||||
tunnel_id=tunnel_id, trace_no=trace_no, scope=test_scope, for_update=True,
|
||||
)
|
||||
if not requires_fifo_skip_reason(inventory.pallet_id, inventory.site_arrived_at, candidate):
|
||||
return None
|
||||
if not reason:
|
||||
raise ServiceException(
|
||||
data='FIFO_SKIP_REASON_REQUIRED',
|
||||
message=f'存在先到托盘 {candidate["pallet_code"]},请填写跳过原因后重新提交',
|
||||
)
|
||||
return {
|
||||
'palletId': candidate['pallet_id'], 'palletCode': candidate['pallet_code'],
|
||||
'batchNo': candidate['batch_no'], 'remainingPackages': candidate['remaining_packages'],
|
||||
'siteArrivedAt': candidate['site_arrived_at'].isoformat(),
|
||||
'selectedSiteArrivedAt': inventory.site_arrived_at.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_exact_idempotent_result(
|
||||
usage: BizUsageRecord,
|
||||
@@ -143,6 +183,32 @@ class UsageService:
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tunnel_entry_success(pallet_id: int, tunnel_id: int, trace_no: str) -> CrudResponseModel:
|
||||
return CrudResponseModel(
|
||||
is_success=True,
|
||||
message='扫码入隧成功',
|
||||
result=TunnelEntrySuccessResponseModel(
|
||||
pallet_id=pallet_id,
|
||||
tunnel_id=tunnel_id,
|
||||
trace_no=trace_no,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_exact_tunnel_entry_result(
|
||||
event: BizPalletEvent,
|
||||
page_object: TunnelEntryCreateModel,
|
||||
request_hash: str,
|
||||
) -> bool:
|
||||
return (
|
||||
event.event_type == PalletEventType.TUNNEL_ENTERED.value
|
||||
and event.after_tunnel_id == page_object.tunnel_id
|
||||
and event.idempotency_key == page_object.idempotency_key
|
||||
and event.request_hash == request_hash
|
||||
and bool(event.trace_no)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _lock_and_reauthorize_usage_site(
|
||||
cls,
|
||||
@@ -294,7 +360,274 @@ class UsageService:
|
||||
return sorted(options, key=lambda option: option.id)
|
||||
|
||||
@classmethod
|
||||
async def scan_usage_pallet_services(
|
||||
async def get_tunnel_options_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
site_id: int,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> list[BusinessOptionModel]:
|
||||
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, '现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='客户现场已停用')
|
||||
tunnels = await MasterDao.list_tunnel(
|
||||
query_db,
|
||||
TunnelPageQueryModel(siteId=site_id, status='0'),
|
||||
test_scope,
|
||||
False,
|
||||
)
|
||||
options = [
|
||||
BusinessOptionModel(
|
||||
id=cls._record_value(tunnel, 'tunnel_id'),
|
||||
code=cls._record_value(tunnel, 'tunnel_code'),
|
||||
name=cls._record_value(tunnel, 'tunnel_name'),
|
||||
)
|
||||
for tunnel in tunnels
|
||||
if cls._record_value(tunnel, 'site_id') == site_id and cls._record_value(tunnel, 'status') == '0'
|
||||
]
|
||||
return sorted(options, key=lambda option: option.id)
|
||||
|
||||
@staticmethod
|
||||
def _build_tunnel_entry_request_hash(page_object: TunnelEntryCreateModel) -> str:
|
||||
fields = {'qr_token': page_object.qr_token.strip(), 'tunnel_id': page_object.tunnel_id}
|
||||
if page_object.fifo_skip_reason:
|
||||
fields['fifo_skip_reason'] = page_object.fifo_skip_reason
|
||||
payload = json.dumps(
|
||||
fields,
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@classmethod
|
||||
async def _recover_tunnel_entry_integrity_error(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: TunnelEntryCreateModel,
|
||||
request_hash: str,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
|
||||
pallet = await InventoryDao.get_pallet_by_qr_token(
|
||||
query_db,
|
||||
page_object.qr_token.strip(),
|
||||
test_scope,
|
||||
)
|
||||
if pallet is None:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
event = await InventoryDao.get_event_by_idempotency_key(
|
||||
query_db,
|
||||
pallet.pallet_id,
|
||||
page_object.idempotency_key,
|
||||
test_scope,
|
||||
)
|
||||
if event is None or not cls._is_exact_tunnel_entry_result(event, page_object, request_hash):
|
||||
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
|
||||
return cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, event.trace_no)
|
||||
finally:
|
||||
await query_db.rollback()
|
||||
|
||||
@classmethod
|
||||
@retry_mysql_lock_conflicts
|
||||
async def enter_tunnel_services( # noqa: PLR0912, PLR0915
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: TunnelEntryCreateModel,
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
|
||||
request_hash = cls._build_tunnel_entry_request_hash(page_object)
|
||||
user_id, user_name = cls._current_identity(current_user)
|
||||
pallet = await InventoryDao.get_pallet_by_qr_token(
|
||||
query_db,
|
||||
page_object.qr_token.strip(),
|
||||
test_scope,
|
||||
)
|
||||
if pallet is None:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
assert_record_in_scope(pallet, test_scope, '托盘')
|
||||
tunnel = await MasterDao.get_tunnel(query_db, page_object.tunnel_id, test_scope)
|
||||
if tunnel is None:
|
||||
raise ServiceException(message='隧道不存在')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
if tunnel.status != '0':
|
||||
raise ServiceException(message='隧道已停用')
|
||||
preflight_tunnel_site_id = tunnel.site_id
|
||||
await cls._assert_pallet_site_access(query_db, current_user, tunnel.site_id)
|
||||
|
||||
existing = await InventoryDao.get_event_by_idempotency_key(
|
||||
query_db,
|
||||
pallet.pallet_id,
|
||||
page_object.idempotency_key,
|
||||
test_scope,
|
||||
)
|
||||
if existing is not None:
|
||||
assert_record_in_scope(existing, test_scope, '托盘事件')
|
||||
if not cls._is_exact_tunnel_entry_result(existing, page_object, request_hash):
|
||||
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
|
||||
result = cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, existing.trace_no)
|
||||
await query_db.rollback()
|
||||
return result
|
||||
|
||||
# rollback 会令 ORM 属性过期;跨事务只传普通值,不触发隐式异步加载。
|
||||
scanned_pallet_id = pallet.pallet_id
|
||||
await query_db.rollback()
|
||||
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
|
||||
inventory = await InventoryDao.get_inventory_for_update(query_db, scanned_pallet_id, test_scope)
|
||||
if inventory is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
|
||||
assert_record_in_scope(inventory, test_scope, '库存')
|
||||
if inventory.site_id is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
|
||||
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
|
||||
if inventory.site_id != preflight_tunnel_site_id:
|
||||
raise ServiceException(message='隧道不属于托盘当前客户现场')
|
||||
|
||||
locked_pallets = await InventoryDao.get_pallets_by_ids(
|
||||
query_db,
|
||||
[inventory.pallet_id],
|
||||
test_scope,
|
||||
)
|
||||
if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != inventory.pallet_id:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
pallet = locked_pallets[0]
|
||||
assert_record_in_scope(pallet, test_scope, '托盘')
|
||||
tunnel = await MasterDao.get_tunnel(
|
||||
query_db,
|
||||
page_object.tunnel_id,
|
||||
test_scope,
|
||||
for_update=True,
|
||||
)
|
||||
if tunnel is None:
|
||||
raise ServiceException(message='隧道不存在')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
if tunnel.status != '0':
|
||||
raise ServiceException(message='隧道已停用')
|
||||
if tunnel.site_id != inventory.site_id:
|
||||
raise ServiceException(message='隧道不属于托盘当前客户现场')
|
||||
existing = await InventoryDao.get_event_by_idempotency_key(
|
||||
query_db,
|
||||
pallet.pallet_id,
|
||||
page_object.idempotency_key,
|
||||
test_scope,
|
||||
)
|
||||
if existing is not None:
|
||||
assert_record_in_scope(existing, test_scope, '托盘事件')
|
||||
if not cls._is_exact_tunnel_entry_result(existing, page_object, request_hash):
|
||||
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
|
||||
result = cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, existing.trace_no)
|
||||
await query_db.rollback()
|
||||
return result
|
||||
|
||||
if pallet.asset_status != '0':
|
||||
raise ServiceException(message='托盘资产已停用,不允许入隧')
|
||||
if (
|
||||
inventory.flow_state
|
||||
not in {
|
||||
PalletFlowState.AT_SITE_UNUSED.value,
|
||||
PalletFlowState.AT_SITE_PARTIAL.value,
|
||||
}
|
||||
or inventory.warehouse_id is not None
|
||||
or inventory.active_filling_id is None
|
||||
or type(inventory.remaining_packages) is not int
|
||||
or inventory.remaining_packages <= 0
|
||||
or inventory.remaining_packages > pallet.capacity_packages
|
||||
):
|
||||
raise ServiceException(message='托盘当前状态不允许入隧')
|
||||
filling = await OperationDao.get_active_filling_for_update(
|
||||
query_db,
|
||||
inventory.active_filling_id,
|
||||
test_scope,
|
||||
)
|
||||
if filling is None:
|
||||
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
|
||||
assert_record_in_scope(filling, test_scope, '活动装填')
|
||||
if (
|
||||
filling.pallet_id != pallet.pallet_id
|
||||
or filling.active_slot != 'ACTIVE'
|
||||
or filling.closed_at is not None
|
||||
):
|
||||
raise ServiceException(message='托盘活动装填周期无效')
|
||||
if inventory.tunnel_id is not None:
|
||||
if inventory.tunnel_id == tunnel.tunnel_id and inventory.trace_no == tunnel.current_trace_no:
|
||||
raise ServiceException(message='托盘已入当前隧道')
|
||||
raise ServiceException(message='托盘已入其他隧道')
|
||||
if inventory.trace_no is not None:
|
||||
raise ServiceException(message='托盘隧道溯源状态不一致')
|
||||
fifo_snapshot = await cls._check_fifo_skip(
|
||||
query_db, inventory, filling, tunnel.tunnel_id, tunnel.current_trace_no,
|
||||
page_object.fifo_skip_reason, test_scope,
|
||||
)
|
||||
if tunnel.current_trace_no is None:
|
||||
tunnel.current_trace_no = f'TN-{secrets.token_hex(16)}'
|
||||
tunnel.current_trace_started_at = datetime.now()
|
||||
elif not tunnel.current_trace_no.strip():
|
||||
raise ServiceException(message='隧道当前溯源号无效')
|
||||
|
||||
current_time = datetime.now()
|
||||
before_state = inventory.flow_state
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
inventory.tunnel_id = tunnel.tunnel_id
|
||||
inventory.trace_no = tunnel.current_trace_no
|
||||
inventory.version += 1
|
||||
inventory.updated_at = current_time
|
||||
event = BizPalletEvent(
|
||||
pallet_id=inventory.pallet_id,
|
||||
event_type=PalletEventType.TUNNEL_ENTERED.value,
|
||||
source_type='TUNNEL',
|
||||
source_id=tunnel.tunnel_id,
|
||||
fifo_skip_reason=page_object.fifo_skip_reason,
|
||||
fifo_recommendation=fifo_snapshot,
|
||||
before_state=before_state,
|
||||
after_state=inventory.flow_state,
|
||||
before_warehouse_id=inventory.warehouse_id,
|
||||
before_site_id=inventory.site_id,
|
||||
before_tunnel_id=None,
|
||||
before_line_id=inventory.current_line_id,
|
||||
after_warehouse_id=inventory.warehouse_id,
|
||||
after_site_id=inventory.site_id,
|
||||
after_tunnel_id=inventory.tunnel_id,
|
||||
after_line_id=inventory.current_line_id,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=inventory.remaining_packages,
|
||||
trace_no=inventory.trace_no,
|
||||
batch_no_snapshot=filling.batch_no,
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
request_hash=request_hash,
|
||||
test_batch_id=test_scope.batch_id,
|
||||
)
|
||||
await InventoryDao.add_event(query_db, inventory, event, test_scope)
|
||||
tunnel.update_by = user_name
|
||||
tunnel.update_time = current_time
|
||||
result = cls._tunnel_entry_success(pallet.pallet_id, tunnel.tunnel_id, tunnel.current_trace_no)
|
||||
await query_db.commit()
|
||||
return result
|
||||
except IntegrityError:
|
||||
await query_db.rollback()
|
||||
return await cls._recover_tunnel_entry_integrity_error(
|
||||
query_db,
|
||||
page_object,
|
||||
cls._build_tunnel_entry_request_hash(page_object),
|
||||
current_user,
|
||||
test_scope,
|
||||
)
|
||||
except Exception:
|
||||
await query_db.rollback()
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
async def scan_usage_pallet_services( # noqa: PLR0912
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
qr_token: str,
|
||||
@@ -314,7 +647,7 @@ class UsageService:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
|
||||
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
|
||||
|
||||
if pallet.qr_token != normalized_qr or pallet.pallet_id != inventory.pallet_id:
|
||||
if normalized_qr not in {pallet.qr_token, pallet.pallet_code} or pallet.pallet_id != inventory.pallet_id:
|
||||
raise ServiceException(message='托盘二维码与库存不一致')
|
||||
if pallet.asset_status != '0':
|
||||
raise ServiceException(message='托盘资产已停用,不允许使用')
|
||||
@@ -328,7 +661,7 @@ class UsageService:
|
||||
or inventory.active_filling_id is None
|
||||
or type(inventory.remaining_packages) is not int
|
||||
or inventory.remaining_packages <= 0
|
||||
or inventory.remaining_packages > pallet.capacity_packages
|
||||
or inventory.remaining_packages > pallet.capacity_packages
|
||||
):
|
||||
raise ServiceException(message='托盘当前状态不允许使用')
|
||||
filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope)
|
||||
@@ -342,25 +675,43 @@ class UsageService:
|
||||
or filling.closed_at is not None
|
||||
):
|
||||
raise ServiceException(message='托盘活动装填周期无效')
|
||||
allowed_actions = ['usage']
|
||||
if inventory.tunnel_id is None:
|
||||
allowed_actions = ['enter_tunnel']
|
||||
else:
|
||||
if inventory.trace_no is None:
|
||||
raise ServiceException(message='托盘隧道溯源状态不一致')
|
||||
tunnel = await MasterDao.get_tunnel(query_db, inventory.tunnel_id, test_scope)
|
||||
if tunnel is None or tunnel.site_id != inventory.site_id:
|
||||
raise ServiceException(message='托盘当前隧道不存在或现场不一致')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
if tunnel.status != '0' or tunnel.current_trace_no != inventory.trace_no:
|
||||
raise ServiceException(message='托盘当前隧道已停用或溯源状态不一致')
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
qr_token=pallet.qr_token,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState(inventory.flow_state),
|
||||
warehouse_id=None,
|
||||
site_id=inventory.site_id,
|
||||
tunnel_id=inventory.tunnel_id,
|
||||
trace_no=inventory.trace_no,
|
||||
current_line_id=inventory.current_line_id,
|
||||
active_filling_id=inventory.active_filling_id,
|
||||
strain_id=filling.strain_id,
|
||||
strain_code=filling.strain_code_snapshot,
|
||||
strain_name=filling.strain_name_snapshot,
|
||||
batch_no=filling.batch_no,
|
||||
production_date=filling.production_date,
|
||||
expiry_date=filling.expiry_date,
|
||||
package_spec=filling.package_spec,
|
||||
capacity_packages=pallet.capacity_packages,
|
||||
remaining_packages=inventory.remaining_packages,
|
||||
allowed_actions=['usage'],
|
||||
allowed_actions=allowed_actions,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@retry_mysql_lock_conflicts
|
||||
async def record_usage_services( # noqa: PLR0912, PLR0915
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
@@ -374,11 +725,16 @@ class UsageService:
|
||||
request_hash = cls._build_request_hash(page_object)
|
||||
user_id, user_name = cls._current_identity(current_user)
|
||||
preflight_site_ids = await cls._site_scope(query_db, current_user)
|
||||
# 产线归属是请求内稳定路由;锁事务仍以库存中的实际现场复核。
|
||||
preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope)
|
||||
if preflight_line is not None:
|
||||
assert_record_in_scope(preflight_line, test_scope, '产线')
|
||||
preflight_site_id = preflight_line.site_id if preflight_line is not None else None
|
||||
preflight_tunnel = await MasterDao.get_tunnel(query_db, page_object.tunnel_id, test_scope)
|
||||
if preflight_tunnel is None:
|
||||
raise ServiceException(message='隧道不存在')
|
||||
assert_record_in_scope(preflight_tunnel, test_scope, '隧道')
|
||||
preflight_site_id = preflight_tunnel.site_id
|
||||
if page_object.line_id is not None:
|
||||
# 仅在预检阶段读取产线以确定批次范围;状态和实际现场归属在锁定库存后复核。
|
||||
preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope)
|
||||
if preflight_line is not None:
|
||||
assert_record_in_scope(preflight_line, test_scope, '产线')
|
||||
existing = None
|
||||
if preflight_site_id is not None and (
|
||||
preflight_site_ids is None or preflight_site_id in preflight_site_ids
|
||||
@@ -494,14 +850,34 @@ class UsageService:
|
||||
assert_record_in_scope(site, test_scope, '现场')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='客户现场已停用')
|
||||
line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True)
|
||||
if line is None:
|
||||
raise ServiceException(message='产线不存在')
|
||||
assert_record_in_scope(line, test_scope, '产线')
|
||||
if line.status != '0':
|
||||
raise ServiceException(message='产线已停用')
|
||||
if line.site_id != inventory.site_id:
|
||||
raise ServiceException(message='产线不属于托盘当前客户现场')
|
||||
tunnel = await MasterDao.get_tunnel(
|
||||
query_db,
|
||||
page_object.tunnel_id,
|
||||
test_scope,
|
||||
for_update=True,
|
||||
)
|
||||
if tunnel is None:
|
||||
raise ServiceException(message='隧道不存在')
|
||||
assert_record_in_scope(tunnel, test_scope, '隧道')
|
||||
if tunnel.status != '0':
|
||||
raise ServiceException(message='隧道已停用')
|
||||
if tunnel.site_id != inventory.site_id:
|
||||
raise ServiceException(message='隧道不属于托盘当前客户现场')
|
||||
if inventory.tunnel_id != tunnel.tunnel_id or not inventory.trace_no:
|
||||
raise ServiceException(message='托盘尚未扫码入当前隧道')
|
||||
if tunnel.current_trace_no != inventory.trace_no:
|
||||
raise ServiceException(message='托盘与隧道的物料溯源号不一致')
|
||||
|
||||
line = None
|
||||
if page_object.line_id is not None:
|
||||
line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True)
|
||||
if line is None:
|
||||
raise ServiceException(message='产线不存在')
|
||||
assert_record_in_scope(line, test_scope, '产线')
|
||||
if line.status != '0':
|
||||
raise ServiceException(message='产线已停用')
|
||||
if line.site_id != inventory.site_id:
|
||||
raise ServiceException(message='产线不属于托盘当前客户现场')
|
||||
|
||||
if pallet.pallet_id != inventory.pallet_id or pallet.qr_token != page_object.qr_token.strip():
|
||||
raise ServiceException(message='托盘二维码与库存不一致')
|
||||
@@ -527,13 +903,22 @@ class UsageService:
|
||||
or filling.closed_at is not None
|
||||
):
|
||||
raise ServiceException(message='托盘活动装填周期无效')
|
||||
if inventory.current_line_id is not None and inventory.current_line_id != line.line_id:
|
||||
if (
|
||||
line is not None
|
||||
and inventory.current_line_id is not None
|
||||
and inventory.current_line_id != line.line_id
|
||||
):
|
||||
raise ServiceException(message='托盘本次驻场周期已绑定其他产线')
|
||||
if type(page_object.used_packages) is not int or page_object.used_packages <= 0:
|
||||
raise ServiceException(message='使用包数必须为正整数')
|
||||
if page_object.used_packages > inventory.remaining_packages:
|
||||
raise ServiceException(message='使用包数不能超过当前余量')
|
||||
|
||||
fifo_snapshot = await cls._check_fifo_skip(
|
||||
query_db, inventory, filling, tunnel.tunnel_id, inventory.trace_no,
|
||||
page_object.fifo_skip_reason, test_scope,
|
||||
)
|
||||
|
||||
remaining_after = inventory.remaining_packages - page_object.used_packages
|
||||
target_state = PalletFlowState.AT_SITE_EMPTY if remaining_after == 0 else PalletFlowState.AT_SITE_PARTIAL
|
||||
PalletStateMachine.ensure_transition(
|
||||
@@ -543,11 +928,16 @@ class UsageService:
|
||||
|
||||
current_time = datetime.now()
|
||||
usage = BizUsageRecord(
|
||||
fifo_skip_reason=page_object.fifo_skip_reason,
|
||||
fifo_recommendation=fifo_snapshot,
|
||||
usage_no=f'US-{secrets.token_hex(16)}',
|
||||
pallet_id=pallet.pallet_id,
|
||||
active_filling_id=filling.filling_id,
|
||||
site_id=inventory.site_id,
|
||||
line_id=line.line_id,
|
||||
tunnel_id=tunnel.tunnel_id,
|
||||
trace_no=inventory.trace_no,
|
||||
batch_no_snapshot=filling.batch_no,
|
||||
line_id=line.line_id if line is not None else inventory.current_line_id,
|
||||
used_packages=page_object.used_packages,
|
||||
remaining_packages_after=remaining_after,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
@@ -564,9 +954,12 @@ class UsageService:
|
||||
before_state = inventory.flow_state
|
||||
before_warehouse_id = inventory.warehouse_id
|
||||
before_site_id = inventory.site_id
|
||||
before_tunnel_id = inventory.tunnel_id
|
||||
before_trace_no = inventory.trace_no
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
inventory.current_line_id = line.line_id
|
||||
if line is not None:
|
||||
inventory.current_line_id = line.line_id
|
||||
inventory.remaining_packages = remaining_after
|
||||
inventory.flow_state = target_state.value
|
||||
if target_state is PalletFlowState.AT_SITE_EMPTY:
|
||||
@@ -583,16 +976,22 @@ class UsageService:
|
||||
event_type=PalletEventType.USAGE_RECORDED.value,
|
||||
source_type='USAGE',
|
||||
source_id=usage_id,
|
||||
fifo_skip_reason=page_object.fifo_skip_reason,
|
||||
fifo_recommendation=fifo_snapshot,
|
||||
before_state=before_state,
|
||||
after_state=target_state.value,
|
||||
before_warehouse_id=before_warehouse_id,
|
||||
before_site_id=before_site_id,
|
||||
before_tunnel_id=before_tunnel_id,
|
||||
before_line_id=before_line_id,
|
||||
after_warehouse_id=inventory.warehouse_id,
|
||||
after_site_id=inventory.site_id,
|
||||
after_tunnel_id=inventory.tunnel_id,
|
||||
after_line_id=inventory.current_line_id,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=remaining_after,
|
||||
trace_no=before_trace_no,
|
||||
batch_no_snapshot=filling.batch_no,
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""入隧/使用命令的有限整事务锁冲突重试,不重放结果未知的连接错误。"""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import wraps
|
||||
from typing import ParamSpec, TypeVar
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
P = ParamSpec('P')
|
||||
R = TypeVar('R')
|
||||
|
||||
|
||||
def retry_mysql_lock_conflicts(operation: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
||||
"""仅用于异常出口已 rollback 的完整业务命令;所有重试沿用原payload/key。"""
|
||||
max_attempts = 3
|
||||
|
||||
@wraps(operation)
|
||||
async def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return await operation(*args, **kwargs)
|
||||
except OperationalError as exc: # noqa: PERF203 -- 有限整事务重试必须逐次捕获锁冲突
|
||||
errno = exc.orig.args[0] if getattr(exc.orig, 'args', ()) else None
|
||||
# 3572=NOWAIT冲突,1213=死锁,1205=锁等待超时;不重试断连/未知提交结果。
|
||||
if errno not in {3572, 1213, 1205} or attempt == max_attempts - 1:
|
||||
raise
|
||||
await asyncio.sleep(0.05 * (attempt + 1) + secrets.randbelow(20) / 1000)
|
||||
raise AssertionError('锁冲突重试未返回结果')
|
||||
|
||||
return wrapped
|
||||
Reference in New Issue
Block a user