功能:实现多托盘发货出库
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
PalletScanResponseModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
ShipmentUpdateModel,
|
||||
)
|
||||
from module_business.service.shipment_service import ShipmentService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
shipment_controller = APIRouterPro(
|
||||
prefix='/business/shipment',
|
||||
tags=['业务管理-整托发货'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/list',
|
||||
summary='获取整托发货单分页列表接口',
|
||||
description='按当前用户仓库与现场双数据范围获取整托发货单',
|
||||
response_model=PageResponseModel[ShipmentModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:list')],
|
||||
)
|
||||
async def get_business_shipment_list(
|
||||
request: Request,
|
||||
shipment_page_query: Annotated[ShipmentPageQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentService.get_shipment_list_services(
|
||||
query_db,
|
||||
shipment_page_query,
|
||||
current_user,
|
||||
is_page=True,
|
||||
)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/warehouse-options',
|
||||
summary='获取发货来源仓库选项接口',
|
||||
description='仅返回当前用户有权访问的启用仓库',
|
||||
response_model=DataResponseModel[list[BusinessOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
|
||||
)
|
||||
async def get_business_shipment_warehouse_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentService.get_warehouse_options_services(query_db, current_user)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/site-options',
|
||||
summary='获取发货目标现场选项接口',
|
||||
description='仅返回当前用户有权访问的启用客户现场',
|
||||
response_model=DataResponseModel[list[BusinessOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
|
||||
)
|
||||
async def get_business_shipment_site_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentService.get_site_options_services(query_db, current_user)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
summary='扫描整托发货托盘接口',
|
||||
description='查询托盘是否可加入指定仓库至现场的整托发货单',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
|
||||
)
|
||||
async def scan_business_shipment_pallet(
|
||||
request: Request,
|
||||
qr_token: Annotated[
|
||||
str,
|
||||
Path(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识'),
|
||||
],
|
||||
shipment_scan_query: Annotated[ShipmentScanQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentService.scan_shipment_pallet_services(
|
||||
query_db,
|
||||
qr_token,
|
||||
shipment_scan_query,
|
||||
current_user,
|
||||
)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_controller.get(
|
||||
'/{shipment_id}',
|
||||
summary='获取整托发货单详情接口',
|
||||
description='按仓库与现场双数据范围获取指定整托发货单',
|
||||
response_model=DataResponseModel[ShipmentModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
|
||||
)
|
||||
async def query_detail_business_shipment(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0, description='发货单ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentService.shipment_detail_services(query_db, shipment_id, current_user)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@shipment_controller.post(
|
||||
'',
|
||||
summary='新增整托发货草稿接口',
|
||||
description='新增多托盘整托发货草稿',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:add')],
|
||||
)
|
||||
@Log(title='整托发货', business_type=BusinessType.INSERT)
|
||||
async def add_business_shipment(
|
||||
request: Request,
|
||||
add_shipment: ShipmentCreateModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentService.add_shipment_services(query_db, add_shipment, current_user)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@shipment_controller.put(
|
||||
'',
|
||||
summary='编辑整托发货草稿接口',
|
||||
description='编辑尚未确认的多托盘整托发货草稿',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:edit')],
|
||||
)
|
||||
@Log(title='整托发货', business_type=BusinessType.UPDATE)
|
||||
async def edit_business_shipment(
|
||||
request: Request,
|
||||
edit_shipment: ShipmentUpdateModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentService.edit_shipment_services(query_db, edit_shipment, current_user)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@shipment_controller.delete(
|
||||
'/{shipment_ids}',
|
||||
summary='删除整托发货草稿接口',
|
||||
description='原子删除一个或多个尚未确认的整托发货草稿',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:remove')],
|
||||
)
|
||||
@Log(title='整托发货', business_type=BusinessType.DELETE)
|
||||
async def delete_business_shipment(
|
||||
request: Request,
|
||||
shipment_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:
|
||||
result = await ShipmentService.delete_shipment_services(
|
||||
query_db,
|
||||
[int(shipment_id) for shipment_id in shipment_ids.split(',')],
|
||||
current_user,
|
||||
)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@shipment_controller.put(
|
||||
'/{shipment_id}/confirm',
|
||||
summary='确认整托发货单接口',
|
||||
description='原子确认整张发货单并将全部托盘移至目标现场',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:shipment:confirm')],
|
||||
)
|
||||
@Log(title='整托发货', business_type=BusinessType.UPDATE)
|
||||
async def confirm_business_shipment(
|
||||
request: Request,
|
||||
shipment_id: Annotated[int, Path(gt=0, description='发货单ID')],
|
||||
confirm_command: ConfirmCommandModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await ShipmentService.confirm_shipment_services(
|
||||
query_db,
|
||||
shipment_id,
|
||||
confirm_command,
|
||||
current_user,
|
||||
)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
@@ -28,16 +28,22 @@ class AccessDao:
|
||||
return mapped_user_id is not None
|
||||
|
||||
@classmethod
|
||||
async def user_has_site(cls, db: AsyncSession, user_id: int, site_id: int) -> bool:
|
||||
async def user_has_site(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> bool:
|
||||
"""判断用户是否关联指定客户现场。"""
|
||||
mapped_user_id = (
|
||||
await db.execute(
|
||||
select(BizUserSite.user_id).where(
|
||||
BizUserSite.user_id == user_id,
|
||||
BizUserSite.site_id == site_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
statement = select(BizUserSite.user_id).where(
|
||||
BizUserSite.user_id == user_id,
|
||||
BizUserSite.site_id == site_id,
|
||||
)
|
||||
if for_update:
|
||||
statement = statement.with_for_update()
|
||||
mapped_user_id = (await db.execute(statement)).scalar_one_or_none()
|
||||
|
||||
return mapped_user_id is not None
|
||||
|
||||
|
||||
@@ -69,6 +69,22 @@ class OperationDao:
|
||||
scoped_warehouse_ids = sorted(set(warehouse_ids))
|
||||
return [BizFillingRecord.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()]
|
||||
|
||||
@staticmethod
|
||||
def _shipment_scope_conditions(
|
||||
warehouse_ids: list[int] | None,
|
||||
site_ids: list[int] | None,
|
||||
) -> list[ColumnElement[bool]]:
|
||||
conditions = []
|
||||
if warehouse_ids is not None:
|
||||
scoped_warehouse_ids = sorted(set(warehouse_ids))
|
||||
conditions.append(
|
||||
BizShipmentOrder.source_warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()
|
||||
)
|
||||
if site_ids is not None:
|
||||
scoped_site_ids = sorted(set(site_ids))
|
||||
conditions.append(BizShipmentOrder.target_site_id.in_(scoped_site_ids) if scoped_site_ids else false())
|
||||
return conditions
|
||||
|
||||
@staticmethod
|
||||
async def _paginate(
|
||||
db: AsyncSession,
|
||||
@@ -243,13 +259,32 @@ class OperationDao:
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
shipment_id: int,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
site_ids: list[int] | None = None,
|
||||
) -> BizShipmentOrder | None:
|
||||
return await cls._get_for_update(
|
||||
shipment = await cls._get_for_update(
|
||||
db,
|
||||
BizShipmentOrder,
|
||||
BizShipmentOrder.shipment_id,
|
||||
shipment_id,
|
||||
*cls._shipment_scope_conditions(warehouse_ids, site_ids),
|
||||
)
|
||||
if shipment is None:
|
||||
return None
|
||||
|
||||
# 父单锁定后读取当前明细,避免 MySQL RR 沿用确认前的快照。
|
||||
item_result = await db.execute(
|
||||
select(BizShipmentItem)
|
||||
.where(BizShipmentItem.shipment_id == shipment_id)
|
||||
.order_by(BizShipmentItem.pallet_id, BizShipmentItem.shipment_item_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
shipment.items = sorted(
|
||||
item_result.scalars().all(),
|
||||
key=lambda item: (item.pallet_id, item.shipment_item_id),
|
||||
)
|
||||
return shipment
|
||||
|
||||
@classmethod
|
||||
async def get_return_for_update(
|
||||
@@ -297,12 +332,15 @@ class OperationDao:
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
idempotency_key: str,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
site_ids: list[int] | None = None,
|
||||
) -> BizShipmentOrder | None:
|
||||
return await cls._get_by_key(
|
||||
db,
|
||||
BizShipmentOrder,
|
||||
BizShipmentOrder.confirm_idempotency_key,
|
||||
idempotency_key,
|
||||
*cls._shipment_scope_conditions(warehouse_ids, site_ids),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -520,8 +558,10 @@ class OperationDao:
|
||||
db: AsyncSession,
|
||||
query_object: ShipmentPageQueryModel,
|
||||
is_page: bool = True,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
site_ids: list[int] | None = None,
|
||||
) -> PageModel | list[dict[str, Any]]:
|
||||
conditions = []
|
||||
conditions = cls._shipment_scope_conditions(warehouse_ids, site_ids)
|
||||
if query_object.shipment_no:
|
||||
conditions.append(BizShipmentOrder.shipment_no.like(f'%{query_object.shipment_no}%'))
|
||||
if query_object.source_warehouse_id:
|
||||
@@ -545,6 +585,8 @@ class OperationDao:
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
shipment_id: int,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
site_ids: list[int] | None = None,
|
||||
) -> BizShipmentOrder | None:
|
||||
return await cls._get_order_detail(
|
||||
db,
|
||||
@@ -554,6 +596,7 @@ class OperationDao:
|
||||
BizShipmentItem,
|
||||
BizShipmentItem.shipment_id,
|
||||
BizShipmentItem.shipment_item_id,
|
||||
*cls._shipment_scope_conditions(warehouse_ids, site_ids),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -38,9 +38,20 @@ class AccessService:
|
||||
db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
site_id: int,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> None:
|
||||
"""校验当前用户是否有权访问指定客户现场。"""
|
||||
if current_user.user.admin:
|
||||
return
|
||||
if not await AccessDao.user_has_site(db, current_user.user.user_id, site_id):
|
||||
if for_update:
|
||||
has_site = await AccessDao.user_has_site(
|
||||
db,
|
||||
current_user.user.user_id,
|
||||
site_id,
|
||||
for_update=True,
|
||||
)
|
||||
else:
|
||||
has_site = await AccessDao.user_has_site(db, current_user.user.user_id, site_id)
|
||||
if not has_site:
|
||||
raise PermissionException(data='', message='无权访问该客户现场')
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Any, 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.domain.enums import DocumentStatus, PalletEventType, PalletFlowState
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord,
|
||||
BizPalletEvent,
|
||||
BizPalletInventory,
|
||||
BizShipmentItem,
|
||||
BizShipmentOrder,
|
||||
)
|
||||
from module_business.entity.vo.master_vo import SitePageQueryModel, WarehousePageQueryModel
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
PalletScanResponseModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
ShipmentUpdateModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
|
||||
|
||||
class ShipmentService:
|
||||
"""多托盘整托发货服务层。"""
|
||||
|
||||
@staticmethod
|
||||
def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]:
|
||||
user = current_user.user
|
||||
if user is None or user.user_id is None or user.user_id <= 0 or not user.user_name:
|
||||
raise ServiceException(message='当前用户信息无效')
|
||||
return user.user_id, user.user_name
|
||||
|
||||
@staticmethod
|
||||
def _is_admin(current_user: CurrentUserModel) -> bool:
|
||||
return bool(current_user.user and current_user.user.admin)
|
||||
|
||||
@staticmethod
|
||||
def _record_value(record: Any, field: str) -> Any:
|
||||
if not isinstance(record, dict):
|
||||
return getattr(record, field)
|
||||
if field in record:
|
||||
return record[field]
|
||||
words = field.split('_')
|
||||
alias = words[0] + ''.join(word.capitalize() for word in words[1:])
|
||||
return record.get(alias)
|
||||
|
||||
@classmethod
|
||||
def _raise_shipment_not_accessible(cls, current_user: CurrentUserModel) -> NoReturn:
|
||||
if cls._is_admin(current_user):
|
||||
raise ServiceException(message='发货单不存在')
|
||||
raise PermissionException(data='', message='无权访问该发货单')
|
||||
|
||||
@classmethod
|
||||
def _raise_pallet_not_accessible(
|
||||
cls,
|
||||
current_user: CurrentUserModel,
|
||||
admin_message: str = '托盘不存在',
|
||||
) -> NoReturn:
|
||||
if cls._is_admin(current_user):
|
||||
raise ServiceException(message=admin_message)
|
||||
raise PermissionException(data='', message='无权访问该托盘')
|
||||
|
||||
@classmethod
|
||||
async def _scopes(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
) -> tuple[list[int] | None, list[int] | None]:
|
||||
if cls._is_admin(current_user):
|
||||
return None, None
|
||||
user_id, _ = cls._current_identity(current_user)
|
||||
warehouse_ids = sorted(set(await AccessDao.list_user_warehouse_ids(query_db, user_id)))
|
||||
site_ids = sorted(set(await AccessDao.list_user_site_ids(query_db, user_id)))
|
||||
return warehouse_ids, site_ids
|
||||
|
||||
@staticmethod
|
||||
async def _validate_active_route(
|
||||
query_db: AsyncSession,
|
||||
source_warehouse_id: int,
|
||||
target_site_id: int,
|
||||
current_user: CurrentUserModel,
|
||||
) -> tuple[Any, Any]:
|
||||
await AccessService.assert_warehouse_access(query_db, current_user, source_warehouse_id)
|
||||
warehouse = await MasterDao.get_warehouse(query_db, source_warehouse_id)
|
||||
if warehouse is None:
|
||||
raise ServiceException(message='来源仓库不存在')
|
||||
if warehouse.status != '0':
|
||||
raise ServiceException(message='来源仓库已停用')
|
||||
await AccessService.assert_site_access(query_db, current_user, target_site_id)
|
||||
site = await MasterDao.get_site(query_db, target_site_id)
|
||||
if site is None:
|
||||
raise ServiceException(message='目标现场不存在')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='目标现场已停用')
|
||||
return warehouse, site
|
||||
|
||||
@staticmethod
|
||||
def _validate_pallet_ids(pallet_ids: list[int]) -> list[int]:
|
||||
if not pallet_ids or any(type(pallet_id) is not int or pallet_id <= 0 for pallet_id in pallet_ids):
|
||||
raise ServiceException(message='托盘ID必须为正整数')
|
||||
if len(pallet_ids) != len(set(pallet_ids)):
|
||||
raise ServiceException(message='托盘ID不能重复')
|
||||
return sorted(pallet_ids)
|
||||
|
||||
@staticmethod
|
||||
def _validate_locked_pallet(
|
||||
pallet: BizPallet,
|
||||
inventory: BizPalletInventory,
|
||||
filling: BizFillingRecord,
|
||||
source_warehouse_id: int,
|
||||
) -> None:
|
||||
if pallet.pallet_id != inventory.pallet_id:
|
||||
raise ServiceException(message='托盘资产与库存不一致')
|
||||
if pallet.asset_status != '0':
|
||||
raise ServiceException(message='托盘资产已停用,不允许发货')
|
||||
if (
|
||||
inventory.flow_state != PalletFlowState.FILLED_IN_WAREHOUSE.value
|
||||
or inventory.warehouse_id != source_warehouse_id
|
||||
or inventory.site_id is not None
|
||||
or inventory.current_line_id is not None
|
||||
or inventory.active_filling_id is None
|
||||
):
|
||||
raise ServiceException(message='托盘不符合整托发货条件')
|
||||
if type(inventory.remaining_packages) is not int or inventory.remaining_packages <= 0:
|
||||
raise ServiceException(message='托盘剩余包数必须为正整数')
|
||||
if inventory.remaining_packages > pallet.capacity_packages:
|
||||
raise ServiceException(message='托盘剩余包数不能超过容量')
|
||||
if type(inventory.version) is not int or inventory.version < 0:
|
||||
raise ServiceException(message='托盘库存版本无效')
|
||||
if (
|
||||
filling.filling_id != inventory.active_filling_id
|
||||
or filling.pallet_id != pallet.pallet_id
|
||||
or filling.warehouse_id != source_warehouse_id
|
||||
or filling.active_slot != 'ACTIVE'
|
||||
):
|
||||
raise ServiceException(message='托盘活动装填周期无效')
|
||||
|
||||
@classmethod
|
||||
async def _lock_and_validate_pallets(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
pallet_ids: list[int],
|
||||
source_warehouse_id: int,
|
||||
) -> tuple[list[BizPalletInventory], list[BizPallet], list[BizFillingRecord]]:
|
||||
ordered_ids = cls._validate_pallet_ids(pallet_ids)
|
||||
inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids)
|
||||
pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids)
|
||||
if (
|
||||
len(inventories) != len(ordered_ids)
|
||||
or len(pallets) != len(ordered_ids)
|
||||
or {inventory.pallet_id for inventory in inventories} != set(ordered_ids)
|
||||
or {pallet.pallet_id for pallet in pallets} != set(ordered_ids)
|
||||
):
|
||||
raise ServiceException(message='存在不存在或库存缺失的托盘')
|
||||
|
||||
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
|
||||
pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets}
|
||||
active_filling_ids = []
|
||||
for pallet_id in ordered_ids:
|
||||
active_filling_id = inventory_by_id[pallet_id].active_filling_id
|
||||
if active_filling_id is None:
|
||||
raise ServiceException(message='托盘不存在活动装填周期')
|
||||
active_filling_ids.append(active_filling_id)
|
||||
|
||||
fillings = []
|
||||
for filling_id in sorted(set(active_filling_ids)):
|
||||
filling = await OperationDao.get_active_filling_for_update(query_db, filling_id)
|
||||
if filling is None:
|
||||
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
|
||||
fillings.append(filling)
|
||||
if len(fillings) != len(ordered_ids):
|
||||
raise ServiceException(message='托盘活动装填周期不完整')
|
||||
filling_by_id = {filling.filling_id: filling for filling in fillings}
|
||||
|
||||
ordered_inventories = [inventory_by_id[pallet_id] for pallet_id in ordered_ids]
|
||||
ordered_pallets = [pallet_by_id[pallet_id] for pallet_id in ordered_ids]
|
||||
ordered_fillings = [filling_by_id[inventory.active_filling_id] for inventory in ordered_inventories]
|
||||
for pallet, inventory, filling in zip(
|
||||
ordered_pallets,
|
||||
ordered_inventories,
|
||||
ordered_fillings,
|
||||
strict=True,
|
||||
):
|
||||
cls._validate_locked_pallet(pallet, inventory, filling, source_warehouse_id)
|
||||
return ordered_inventories, ordered_pallets, ordered_fillings
|
||||
|
||||
@staticmethod
|
||||
def _build_items(
|
||||
shipment_id: int,
|
||||
pallet_ids: list[int],
|
||||
user_name: str,
|
||||
current_time: datetime,
|
||||
) -> list[BizShipmentItem]:
|
||||
return [
|
||||
BizShipmentItem(
|
||||
shipment_id=shipment_id,
|
||||
pallet_id=pallet_id,
|
||||
active_filling_id=None,
|
||||
package_qty=None,
|
||||
create_by=user_name,
|
||||
create_time=current_time,
|
||||
)
|
||||
for pallet_id in sorted(pallet_ids)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _success(message: str) -> CrudResponseModel:
|
||||
return CrudResponseModel(is_success=True, message=message)
|
||||
|
||||
@staticmethod
|
||||
def _build_confirm_request_hash(shipment_id: int) -> str:
|
||||
payload = json.dumps(
|
||||
{'shipment_id': shipment_id},
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@classmethod
|
||||
async def get_shipment_list_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
query_object: ShipmentPageQueryModel,
|
||||
current_user: CurrentUserModel,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[ShipmentModel]:
|
||||
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
|
||||
if (
|
||||
warehouse_ids is not None
|
||||
and query_object.source_warehouse_id is not None
|
||||
and query_object.source_warehouse_id not in warehouse_ids
|
||||
):
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
if (
|
||||
site_ids is not None
|
||||
and query_object.target_site_id is not None
|
||||
and query_object.target_site_id not in site_ids
|
||||
):
|
||||
raise PermissionException(data='', message='无权访问该客户现场')
|
||||
if warehouse_ids == [] or site_ids == []:
|
||||
if is_page:
|
||||
return PageModel(
|
||||
rows=[],
|
||||
pageNum=query_object.page_num,
|
||||
pageSize=query_object.page_size,
|
||||
total=0,
|
||||
hasNext=False,
|
||||
)
|
||||
return []
|
||||
|
||||
result = await OperationDao.get_shipment_list(
|
||||
query_db,
|
||||
query_object,
|
||||
is_page,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
)
|
||||
rows = result.rows if isinstance(result, PageModel) else result
|
||||
models = [ShipmentModel.model_validate(record) for record in rows]
|
||||
if isinstance(result, PageModel):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
|
||||
@classmethod
|
||||
async def shipment_detail_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
shipment_id: int,
|
||||
current_user: CurrentUserModel,
|
||||
) -> ShipmentModel:
|
||||
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
|
||||
shipment = await OperationDao.get_shipment_by_id(
|
||||
query_db,
|
||||
shipment_id,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
)
|
||||
if shipment is None:
|
||||
cls._raise_shipment_not_accessible(current_user)
|
||||
return ShipmentModel.model_validate(shipment)
|
||||
|
||||
@classmethod
|
||||
async def get_warehouse_options_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
) -> list[BusinessOptionModel]:
|
||||
warehouse_ids, _ = await cls._scopes(query_db, current_user)
|
||||
allowed_ids = None if warehouse_ids is None else set(warehouse_ids)
|
||||
warehouses = await MasterDao.list_warehouse(
|
||||
query_db,
|
||||
WarehousePageQueryModel(status='0'),
|
||||
False,
|
||||
)
|
||||
options = []
|
||||
for warehouse in warehouses:
|
||||
warehouse_id = cls._record_value(warehouse, 'warehouse_id')
|
||||
if cls._record_value(warehouse, 'status') != '0':
|
||||
continue
|
||||
if allowed_ids is not None and warehouse_id not in allowed_ids:
|
||||
continue
|
||||
options.append(
|
||||
BusinessOptionModel(
|
||||
id=warehouse_id,
|
||||
code=cls._record_value(warehouse, 'warehouse_code'),
|
||||
name=cls._record_value(warehouse, 'warehouse_name'),
|
||||
)
|
||||
)
|
||||
return sorted(options, key=lambda option: option.id)
|
||||
|
||||
@classmethod
|
||||
async def get_site_options_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
) -> list[BusinessOptionModel]:
|
||||
_, site_ids = await cls._scopes(query_db, current_user)
|
||||
allowed_ids = None if site_ids is None else set(site_ids)
|
||||
sites = await MasterDao.list_site(
|
||||
query_db,
|
||||
SitePageQueryModel(status='0'),
|
||||
False,
|
||||
)
|
||||
options = []
|
||||
for site in sites:
|
||||
site_id = cls._record_value(site, 'site_id')
|
||||
if cls._record_value(site, 'status') != '0':
|
||||
continue
|
||||
if allowed_ids is not None and site_id not in allowed_ids:
|
||||
continue
|
||||
options.append(
|
||||
BusinessOptionModel(
|
||||
id=site_id,
|
||||
code=cls._record_value(site, 'site_code'),
|
||||
name=cls._record_value(site, 'site_name'),
|
||||
)
|
||||
)
|
||||
return sorted(options, key=lambda option: option.id)
|
||||
|
||||
@staticmethod
|
||||
def _validate_scan_pallet(
|
||||
pallet: BizPallet,
|
||||
inventory: BizPalletInventory,
|
||||
filling: BizFillingRecord,
|
||||
source_warehouse_id: int,
|
||||
) -> None:
|
||||
if pallet.asset_status != '0':
|
||||
raise ServiceException(message='托盘资产已停用,不允许发货')
|
||||
if (
|
||||
inventory.flow_state != PalletFlowState.FILLED_IN_WAREHOUSE.value
|
||||
or inventory.warehouse_id != source_warehouse_id
|
||||
or inventory.site_id is not None
|
||||
or inventory.current_line_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='托盘不符合整托发货条件')
|
||||
if (
|
||||
filling.filling_id != inventory.active_filling_id
|
||||
or filling.pallet_id != pallet.pallet_id
|
||||
or filling.warehouse_id != source_warehouse_id
|
||||
or filling.active_slot != 'ACTIVE'
|
||||
):
|
||||
raise ServiceException(message='托盘活动装填周期无效')
|
||||
|
||||
@classmethod
|
||||
async def scan_shipment_pallet_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
qr_token: str,
|
||||
query_object: ShipmentScanQueryModel,
|
||||
current_user: CurrentUserModel,
|
||||
) -> PalletScanResponseModel:
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
query_object.source_warehouse_id,
|
||||
)
|
||||
warehouse = await MasterDao.get_warehouse(query_db, query_object.source_warehouse_id)
|
||||
if warehouse is None:
|
||||
raise ServiceException(message='来源仓库不存在')
|
||||
if warehouse.status != '0':
|
||||
raise ServiceException(message='来源仓库已停用')
|
||||
await AccessService.assert_site_access(
|
||||
query_db,
|
||||
current_user,
|
||||
query_object.target_site_id,
|
||||
)
|
||||
site = await MasterDao.get_site(query_db, query_object.target_site_id)
|
||||
if site is None:
|
||||
raise ServiceException(message='目标现场不存在')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='目标现场已停用')
|
||||
|
||||
pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token)
|
||||
if pallet is None:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id)
|
||||
if inventory is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
|
||||
if inventory.pallet_id != pallet.pallet_id or inventory.warehouse_id != query_object.source_warehouse_id:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘不在来源仓库')
|
||||
if inventory.site_id is not None or inventory.current_line_id is not None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘已在客户现场')
|
||||
if inventory.active_filling_id is None:
|
||||
raise ServiceException(message='托盘不存在活动装填周期')
|
||||
filling = await OperationDao.get_filling_by_id(
|
||||
query_db,
|
||||
inventory.active_filling_id,
|
||||
warehouse_ids=[query_object.source_warehouse_id],
|
||||
)
|
||||
if filling is None:
|
||||
raise ServiceException(message='托盘活动装填周期不存在')
|
||||
cls._validate_scan_pallet(
|
||||
pallet,
|
||||
inventory,
|
||||
filling,
|
||||
query_object.source_warehouse_id,
|
||||
)
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState.FILLED_IN_WAREHOUSE,
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
site_id=None,
|
||||
current_line_id=None,
|
||||
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,
|
||||
package_spec=filling.package_spec,
|
||||
capacity_packages=pallet.capacity_packages,
|
||||
remaining_packages=inventory.remaining_packages,
|
||||
allowed_actions=['shipment'],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def add_shipment_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: ShipmentCreateModel,
|
||||
current_user: CurrentUserModel,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
_, user_name = cls._current_identity(current_user)
|
||||
await cls._validate_active_route(
|
||||
query_db,
|
||||
page_object.source_warehouse_id,
|
||||
page_object.target_site_id,
|
||||
current_user,
|
||||
)
|
||||
ordered_ids = cls._validate_pallet_ids(page_object.pallet_ids)
|
||||
await cls._lock_and_validate_pallets(
|
||||
query_db,
|
||||
ordered_ids,
|
||||
page_object.source_warehouse_id,
|
||||
)
|
||||
|
||||
current_time = datetime.now()
|
||||
shipment = BizShipmentOrder(
|
||||
shipment_no=f'SH-{secrets.token_hex(16)}',
|
||||
source_warehouse_id=page_object.source_warehouse_id,
|
||||
target_site_id=page_object.target_site_id,
|
||||
status=DocumentStatus.DRAFT.value,
|
||||
create_by=user_name,
|
||||
create_time=current_time,
|
||||
update_by=user_name,
|
||||
update_time=current_time,
|
||||
)
|
||||
shipment = await OperationDao.add_shipment(query_db, shipment)
|
||||
await OperationDao.replace_shipment_items(
|
||||
query_db,
|
||||
shipment.shipment_id,
|
||||
cls._build_items(shipment.shipment_id, ordered_ids, user_name, current_time),
|
||||
)
|
||||
await query_db.commit()
|
||||
return cls._success('新增成功')
|
||||
except IntegrityError:
|
||||
await query_db.rollback()
|
||||
raise ServiceException(message='新增发货单失败,发货单号或明细已存在') from None
|
||||
except Exception:
|
||||
await query_db.rollback()
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
async def edit_shipment_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: ShipmentUpdateModel,
|
||||
current_user: CurrentUserModel,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
_, user_name = cls._current_identity(current_user)
|
||||
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
|
||||
shipment = await OperationDao.get_shipment_for_update(
|
||||
query_db,
|
||||
page_object.shipment_id,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
)
|
||||
if shipment is None:
|
||||
cls._raise_shipment_not_accessible(current_user)
|
||||
if shipment.status != DocumentStatus.DRAFT.value:
|
||||
raise ServiceException(message='已确认发货单不允许编辑')
|
||||
if any(item.active_filling_id is not None or item.package_qty is not None for item in shipment.items):
|
||||
raise ServiceException(message='发货草稿明细快照异常')
|
||||
|
||||
await cls._validate_active_route(
|
||||
query_db,
|
||||
page_object.source_warehouse_id,
|
||||
page_object.target_site_id,
|
||||
current_user,
|
||||
)
|
||||
ordered_ids = cls._validate_pallet_ids(page_object.pallet_ids)
|
||||
await cls._lock_and_validate_pallets(
|
||||
query_db,
|
||||
ordered_ids,
|
||||
page_object.source_warehouse_id,
|
||||
)
|
||||
|
||||
current_time = datetime.now()
|
||||
shipment.source_warehouse_id = page_object.source_warehouse_id
|
||||
shipment.target_site_id = page_object.target_site_id
|
||||
shipment.update_by = user_name
|
||||
shipment.update_time = current_time
|
||||
await OperationDao.update_shipment(query_db, shipment)
|
||||
await OperationDao.replace_shipment_items(
|
||||
query_db,
|
||||
shipment.shipment_id,
|
||||
cls._build_items(shipment.shipment_id, ordered_ids, user_name, current_time),
|
||||
)
|
||||
await query_db.commit()
|
||||
return cls._success('修改成功')
|
||||
except IntegrityError:
|
||||
await query_db.rollback()
|
||||
raise ServiceException(message='修改发货单失败,单据或明细状态已变化') from None
|
||||
except Exception:
|
||||
await query_db.rollback()
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
async def delete_shipment_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
shipment_ids: list[int],
|
||||
current_user: CurrentUserModel,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
if not shipment_ids or any(
|
||||
type(shipment_id) is not int or shipment_id <= 0 for shipment_id in shipment_ids
|
||||
):
|
||||
raise ServiceException(message='发货单ID必须为正整数')
|
||||
if len(shipment_ids) != len(set(shipment_ids)):
|
||||
raise ServiceException(message='发货单ID不能重复')
|
||||
|
||||
ordered_ids = sorted(shipment_ids)
|
||||
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
|
||||
for shipment_id in ordered_ids:
|
||||
shipment = await OperationDao.get_shipment_for_update(
|
||||
query_db,
|
||||
shipment_id,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
)
|
||||
if shipment is None:
|
||||
cls._raise_shipment_not_accessible(current_user)
|
||||
if shipment.status != DocumentStatus.DRAFT.value:
|
||||
raise ServiceException(message='已确认发货单不允许删除')
|
||||
|
||||
for shipment_id in ordered_ids:
|
||||
await OperationDao.delete_shipment(query_db, shipment_id)
|
||||
await query_db.commit()
|
||||
return cls._success('删除成功')
|
||||
except IntegrityError:
|
||||
await query_db.rollback()
|
||||
raise ServiceException(message='删除发货单失败,单据状态或关联数据已变化') from None
|
||||
except Exception:
|
||||
await query_db.rollback()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _is_exact_confirmed(
|
||||
shipment: BizShipmentOrder,
|
||||
shipment_id: int,
|
||||
idempotency_key: str,
|
||||
request_hash: str,
|
||||
) -> bool:
|
||||
return (
|
||||
shipment.shipment_id == shipment_id
|
||||
and shipment.status == DocumentStatus.CONFIRMED.value
|
||||
and shipment.confirm_idempotency_key == idempotency_key
|
||||
and shipment.confirm_request_hash == request_hash
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _lock_and_reauthorize_route(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
shipment: BizShipmentOrder,
|
||||
current_user: CurrentUserModel,
|
||||
) -> None:
|
||||
warehouse = await MasterDao.get_warehouse(
|
||||
query_db,
|
||||
shipment.source_warehouse_id,
|
||||
for_update=True,
|
||||
)
|
||||
if warehouse is None:
|
||||
raise ServiceException(message='来源仓库不存在')
|
||||
if warehouse.status != '0':
|
||||
raise ServiceException(message='来源仓库已停用')
|
||||
try:
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
shipment.source_warehouse_id,
|
||||
for_update=True,
|
||||
)
|
||||
except PermissionException:
|
||||
cls._raise_shipment_not_accessible(current_user)
|
||||
|
||||
site = await MasterDao.get_site(
|
||||
query_db,
|
||||
shipment.target_site_id,
|
||||
for_update=True,
|
||||
)
|
||||
if site is None:
|
||||
raise ServiceException(message='目标现场不存在')
|
||||
if site.status != '0':
|
||||
raise ServiceException(message='目标现场已停用')
|
||||
try:
|
||||
await AccessService.assert_site_access(
|
||||
query_db,
|
||||
current_user,
|
||||
shipment.target_site_id,
|
||||
for_update=True,
|
||||
)
|
||||
except PermissionException:
|
||||
cls._raise_shipment_not_accessible(current_user)
|
||||
|
||||
@classmethod
|
||||
async def _recover_confirm_integrity_error(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
shipment_id: int,
|
||||
idempotency_key: str,
|
||||
request_hash: str,
|
||||
current_user: CurrentUserModel,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
|
||||
recovered = await OperationDao.get_shipment_by_confirm_idempotency_key(
|
||||
query_db,
|
||||
idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
)
|
||||
if recovered is None:
|
||||
raise ServiceException(message='确认发货失败,幂等冲突')
|
||||
if not cls._is_exact_confirmed(
|
||||
recovered,
|
||||
shipment_id,
|
||||
idempotency_key,
|
||||
request_hash,
|
||||
):
|
||||
raise ServiceException(message='确认发货失败,幂等键已被其他请求使用')
|
||||
await cls._validate_active_route(
|
||||
query_db,
|
||||
recovered.source_warehouse_id,
|
||||
recovered.target_site_id,
|
||||
current_user,
|
||||
)
|
||||
return cls._success('确认成功')
|
||||
finally:
|
||||
await query_db.rollback()
|
||||
|
||||
@classmethod
|
||||
async def confirm_shipment_services( # noqa: PLR0912, PLR0915
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
shipment_id: int,
|
||||
page_object: ConfirmCommandModel,
|
||||
current_user: CurrentUserModel,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
user_id, user_name = cls._current_identity(current_user)
|
||||
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
|
||||
shipment = await OperationDao.get_shipment_by_id(
|
||||
query_db,
|
||||
shipment_id,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
)
|
||||
if shipment is None:
|
||||
cls._raise_shipment_not_accessible(current_user)
|
||||
|
||||
request_hash = cls._build_confirm_request_hash(shipment_id)
|
||||
idempotency_key = page_object.idempotency_key
|
||||
existing = await OperationDao.get_shipment_by_confirm_idempotency_key(
|
||||
query_db,
|
||||
idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
)
|
||||
if existing is not None and not cls._is_exact_confirmed(
|
||||
existing,
|
||||
shipment_id,
|
||||
idempotency_key,
|
||||
request_hash,
|
||||
):
|
||||
raise ServiceException(message='确认发货失败,幂等键已被其他请求使用')
|
||||
|
||||
locked_shipment = await OperationDao.get_shipment_for_update(
|
||||
query_db,
|
||||
shipment_id,
|
||||
warehouse_ids=warehouse_ids,
|
||||
site_ids=site_ids,
|
||||
)
|
||||
if locked_shipment is None:
|
||||
cls._raise_shipment_not_accessible(current_user)
|
||||
if locked_shipment.status == DocumentStatus.CONFIRMED.value:
|
||||
if cls._is_exact_confirmed(
|
||||
locked_shipment,
|
||||
shipment_id,
|
||||
idempotency_key,
|
||||
request_hash,
|
||||
):
|
||||
await cls._lock_and_reauthorize_route(query_db, locked_shipment, current_user)
|
||||
await query_db.rollback()
|
||||
return cls._success('确认成功')
|
||||
raise ServiceException(message='发货单已确认,幂等信息不匹配')
|
||||
if locked_shipment.status != DocumentStatus.DRAFT.value:
|
||||
raise ServiceException(message='当前发货单状态不允许确认')
|
||||
if existing is not None:
|
||||
raise ServiceException(message='确认发货失败,并发状态不一致')
|
||||
if locked_shipment.confirm_idempotency_key is not None or locked_shipment.confirm_request_hash is not None:
|
||||
raise ServiceException(message='发货单确认信息不完整')
|
||||
|
||||
items = list(locked_shipment.items)
|
||||
pallet_ids = [item.pallet_id for item in items]
|
||||
ordered_ids = cls._validate_pallet_ids(pallet_ids)
|
||||
if any(item.active_filling_id is not None or item.package_qty is not None for item in items):
|
||||
raise ServiceException(message='发货草稿明细快照异常')
|
||||
inventories, _, _ = await cls._lock_and_validate_pallets(
|
||||
query_db,
|
||||
ordered_ids,
|
||||
locked_shipment.source_warehouse_id,
|
||||
)
|
||||
await cls._lock_and_reauthorize_route(query_db, locked_shipment, current_user)
|
||||
|
||||
for inventory in inventories:
|
||||
PalletStateMachine.ensure_transition(
|
||||
PalletFlowState(inventory.flow_state),
|
||||
PalletFlowState.AT_SITE_UNUSED,
|
||||
)
|
||||
|
||||
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
|
||||
current_time = datetime.now()
|
||||
locked_shipment.status = DocumentStatus.CONFIRMED.value
|
||||
locked_shipment.confirm_idempotency_key = idempotency_key
|
||||
locked_shipment.confirm_request_hash = request_hash
|
||||
locked_shipment.confirmed_by = user_id
|
||||
locked_shipment.confirmed_at = current_time
|
||||
locked_shipment.update_by = user_name
|
||||
locked_shipment.update_time = current_time
|
||||
for item in items:
|
||||
inventory = inventory_by_id[item.pallet_id]
|
||||
item.active_filling_id = inventory.active_filling_id
|
||||
item.package_qty = inventory.remaining_packages
|
||||
await OperationDao.update_shipment(query_db, locked_shipment)
|
||||
|
||||
for inventory in inventories:
|
||||
before_state = inventory.flow_state
|
||||
before_warehouse_id = inventory.warehouse_id
|
||||
before_site_id = inventory.site_id
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
|
||||
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value
|
||||
inventory.warehouse_id = None
|
||||
inventory.site_id = locked_shipment.target_site_id
|
||||
inventory.current_line_id = None
|
||||
inventory.version += 1
|
||||
inventory.updated_at = current_time
|
||||
event = BizPalletEvent(
|
||||
pallet_id=inventory.pallet_id,
|
||||
event_type=PalletEventType.SHIPMENT_CONFIRMED.value,
|
||||
source_type='SHIPMENT',
|
||||
source_id=shipment_id,
|
||||
before_state=before_state,
|
||||
after_state=PalletFlowState.AT_SITE_UNUSED.value,
|
||||
before_warehouse_id=before_warehouse_id,
|
||||
before_site_id=before_site_id,
|
||||
before_line_id=before_line_id,
|
||||
after_warehouse_id=None,
|
||||
after_site_id=locked_shipment.target_site_id,
|
||||
after_line_id=None,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=inventory.remaining_packages,
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=idempotency_key,
|
||||
request_hash=request_hash,
|
||||
)
|
||||
await InventoryDao.add_event(query_db, inventory, event)
|
||||
|
||||
await query_db.commit()
|
||||
return cls._success('确认成功')
|
||||
except IntegrityError:
|
||||
await query_db.rollback()
|
||||
return await cls._recover_confirm_integrity_error(
|
||||
query_db,
|
||||
shipment_id,
|
||||
page_object.idempotency_key,
|
||||
cls._build_confirm_request_hash(shipment_id),
|
||||
current_user,
|
||||
)
|
||||
except Exception:
|
||||
await query_db.rollback()
|
||||
raise
|
||||
Reference in New Issue
Block a user