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.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.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) 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, ) await cls._lock_and_authorize_routes( query_db, current_user, warehouse_ids={page_object.source_warehouse_id}, site_ids={page_object.target_site_id}, hidden_warehouse_ids=set(), hidden_site_ids=set(), require_active=True, ) 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, ) await cls._lock_and_authorize_routes( query_db, current_user, warehouse_ids={ shipment.source_warehouse_id, page_object.source_warehouse_id, }, site_ids={ shipment.target_site_id, page_object.target_site_id, }, hidden_warehouse_ids={shipment.source_warehouse_id}, hidden_site_ids={shipment.target_site_id}, require_active=True, ) 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) locked_shipments = [] 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='已确认发货单不允许删除') locked_shipments.append(shipment) route_warehouse_ids = {shipment.source_warehouse_id for shipment in locked_shipments} route_site_ids = {shipment.target_site_id for shipment in locked_shipments} await cls._lock_and_authorize_routes( query_db, current_user, warehouse_ids=route_warehouse_ids, site_ids=route_site_ids, hidden_warehouse_ids=route_warehouse_ids, hidden_site_ids=route_site_ids, require_active=False, ) 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_authorize_routes( cls, query_db: AsyncSession, current_user: CurrentUserModel, *, warehouse_ids: set[int], site_ids: set[int], hidden_warehouse_ids: set[int], hidden_site_ids: set[int], require_active: bool, ) -> None: warehouses: dict[int, Any] = {} warehouse_permission_errors: dict[int, PermissionException] = {} for warehouse_id in sorted(warehouse_ids): warehouse = await MasterDao.get_warehouse( query_db, warehouse_id, for_update=True, ) warehouses[warehouse_id] = warehouse try: await AccessService.assert_warehouse_access( query_db, current_user, warehouse_id, for_update=True, ) except PermissionException as exc: warehouse_permission_errors[warehouse_id] = exc sites: dict[int, Any] = {} site_permission_errors: dict[int, PermissionException] = {} for site_id in sorted(site_ids): site = await MasterDao.get_site( query_db, site_id, for_update=True, ) sites[site_id] = site try: await AccessService.assert_site_access( query_db, current_user, site_id, for_update=True, ) except PermissionException as exc: site_permission_errors[site_id] = exc if hidden_warehouse_ids & warehouse_permission_errors.keys() or ( hidden_site_ids & site_permission_errors.keys() ): cls._raise_shipment_not_accessible(current_user) explicit_permission_errors = [ warehouse_permission_errors[warehouse_id] for warehouse_id in sorted(warehouse_permission_errors) ] explicit_permission_errors.extend(site_permission_errors[site_id] for site_id in sorted(site_permission_errors)) if explicit_permission_errors: raise explicit_permission_errors[0] for warehouse_id in sorted(warehouses): warehouse = warehouses[warehouse_id] if warehouse is None: raise ServiceException(message='来源仓库不存在') if require_active and warehouse.status != '0': raise ServiceException(message='来源仓库已停用') for site_id in sorted(sites): site = sites[site_id] if site is None: raise ServiceException(message='目标现场不存在') if require_active and site.status != '0': raise ServiceException(message='目标现场已停用') @classmethod async def _lock_and_reauthorize_route( cls, query_db: AsyncSession, shipment: BizShipmentOrder, current_user: CurrentUserModel, *, require_active: bool = True, ) -> None: await cls._lock_and_authorize_routes( query_db, current_user, warehouse_ids={shipment.source_warehouse_id}, site_ids={shipment.target_site_id}, hidden_warehouse_ids={shipment.source_warehouse_id}, hidden_site_ids={shipment.target_site_id}, require_active=require_active, ) @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='确认发货失败,幂等键已被其他请求使用') locked_recovered = await OperationDao.get_shipment_for_update( query_db, shipment_id, warehouse_ids=warehouse_ids, site_ids=site_ids, ) if locked_recovered is None: cls._raise_shipment_not_accessible(current_user) if not cls._is_exact_confirmed( locked_recovered, shipment_id, idempotency_key, request_hash, ): raise ServiceException(message='确认发货失败,幂等键已被其他请求使用') await cls._lock_and_reauthorize_route( query_db, locked_recovered, current_user, require_active=False, ) 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, require_active=False, ) 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