更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
@@ -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