import hashlib import json import secrets from datetime import datetime from typing import Any 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 ( BizInboundItem, BizInboundOrder, BizPalletEvent, BizPalletInventory, ) from module_business.entity.vo.master_vo import WarehousePageQueryModel from module_business.entity.vo.operation_vo import ( BusinessOptionModel, ConfirmCommandModel, InboundCreateModel, InboundModel, InboundPageQueryModel, InboundScanQueryModel, InboundUpdateModel, PalletScanResponseModel, ) from module_business.service.access_service import AccessService class InboundService: """空托盘首次入库服务层。""" @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) @staticmethod def _build_confirm_request_hash(inbound_id: int) -> str: payload = json.dumps( {'inbound_id': inbound_id}, sort_keys=True, separators=(',', ':'), ).encode() return hashlib.sha256(payload).hexdigest() @classmethod async def _warehouse_scope( cls, query_db: AsyncSession, current_user: CurrentUserModel, ) -> list[int] | None: if cls._is_admin(current_user): return None user_id, _ = cls._current_identity(current_user) return sorted(set(await AccessDao.list_user_warehouse_ids(query_db, user_id))) @staticmethod async def _validate_active_warehouse( query_db: AsyncSession, warehouse_id: int, current_user: CurrentUserModel, ) -> Any: warehouse = await MasterDao.get_warehouse(query_db, warehouse_id) if warehouse is None: raise ServiceException(message='目标仓库不存在') if warehouse.status != '0': raise ServiceException(message='目标仓库已停用') await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id) return warehouse @staticmethod def _validate_pallet_pair(pallet: BizPallet, inventory: BizPalletInventory) -> None: if pallet.asset_status != '0': raise ServiceException(message='托盘资产已停用,不允许首次入库') if ( inventory.flow_state != PalletFlowState.UNSTOCKED.value or inventory.warehouse_id is not None or inventory.site_id is not None or inventory.current_line_id is not None or inventory.active_filling_id is not None or inventory.remaining_packages != 0 ): raise ServiceException(message='托盘不符合首次入库条件') if not isinstance(inventory.version, int) or inventory.version < 0: raise ServiceException(message='托盘库存版本无效') @classmethod async def _lock_and_validate_pallets( cls, query_db: AsyncSession, pallet_ids: list[int], ) -> tuple[list[BizPalletInventory], list[BizPallet]]: ordered_ids = sorted(pallet_ids) inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids) pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids) expected_ids = set(ordered_ids) inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories} pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets} if set(inventory_by_id) != expected_ids or set(pallet_by_id) != expected_ids: raise ServiceException(message='存在不存在或库存缺失的托盘') 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] for pallet, inventory in zip(ordered_pallets, ordered_inventories, strict=True): cls._validate_pallet_pair(pallet, inventory) return ordered_inventories, ordered_pallets @staticmethod def _build_items( inbound_id: int, pallet_ids: list[int], user_name: str, current_time: datetime, ) -> list[BizInboundItem]: return [ BizInboundItem( inbound_id=inbound_id, pallet_id=pallet_id, 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) @classmethod async def get_inbound_list_services( cls, query_db: AsyncSession, query_object: InboundPageQueryModel, current_user: CurrentUserModel, is_page: bool = True, ) -> PageModel | list[InboundModel]: warehouse_ids = await cls._warehouse_scope(query_db, current_user) if ( warehouse_ids is not None and query_object.warehouse_id is not None and query_object.warehouse_id not in warehouse_ids ): raise PermissionException(data='', message='无权访问该仓库') result = await OperationDao.get_inbound_list( query_db, query_object, is_page, warehouse_ids=warehouse_ids, ) rows = result.rows if isinstance(result, PageModel) else result models = [InboundModel.model_validate(record) for record in rows] if isinstance(result, PageModel): return result.model_copy(update={'rows': models}) return models @classmethod async def inbound_detail_services( cls, query_db: AsyncSession, inbound_id: int, current_user: CurrentUserModel, ) -> InboundModel: inbound = await OperationDao.get_inbound_by_id(query_db, inbound_id) if inbound is None: raise ServiceException(message='入库单不存在') await AccessService.assert_warehouse_access(query_db, current_user, inbound.warehouse_id) return InboundModel.model_validate(inbound) @classmethod async def get_warehouse_options_services( cls, query_db: AsyncSession, current_user: CurrentUserModel, ) -> list[BusinessOptionModel]: warehouse_ids = await cls._warehouse_scope(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 scan_inbound_pallet_services( cls, query_db: AsyncSession, qr_token: str, query_object: InboundScanQueryModel, current_user: CurrentUserModel, ) -> PalletScanResponseModel: await AccessService.assert_warehouse_access( query_db, current_user, query_object.warehouse_id, ) warehouse = await MasterDao.get_warehouse(query_db, query_object.warehouse_id) if warehouse is None: raise ServiceException(message='目标仓库不存在') if warehouse.status != '0': raise ServiceException(message='目标仓库已停用') pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token) if pallet is None: raise ServiceException(message='托盘不存在') inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id) if inventory is None: raise ServiceException(message='托盘库存不存在') cls._validate_pallet_pair(pallet, inventory) return PalletScanResponseModel( pallet_id=pallet.pallet_id, pallet_code=pallet.pallet_code, flow_state=PalletFlowState.UNSTOCKED, warehouse_id=None, site_id=None, current_line_id=None, active_filling_id=None, strain_id=None, strain_code=None, strain_name=None, batch_no=None, package_spec=None, capacity_packages=pallet.capacity_packages, remaining_packages=0, allowed_actions=['inbound'], ) @classmethod async def add_inbound_services( cls, query_db: AsyncSession, page_object: InboundCreateModel, current_user: CurrentUserModel, ) -> CrudResponseModel: try: _, user_name = cls._current_identity(current_user) await cls._validate_active_warehouse( query_db, page_object.warehouse_id, current_user, ) ordered_ids = sorted(page_object.pallet_ids) await cls._lock_and_validate_pallets(query_db, ordered_ids) current_time = datetime.now() inbound = BizInboundOrder( inbound_no=f'IN-{secrets.token_hex(16)}', warehouse_id=page_object.warehouse_id, status=DocumentStatus.DRAFT.value, create_by=user_name, create_time=current_time, update_by=user_name, update_time=current_time, ) inbound = await OperationDao.add_inbound(query_db, inbound) await OperationDao.replace_inbound_items( query_db, inbound.inbound_id, cls._build_items(inbound.inbound_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_inbound_services( cls, query_db: AsyncSession, page_object: InboundUpdateModel, current_user: CurrentUserModel, ) -> CrudResponseModel: try: _, user_name = cls._current_identity(current_user) inbound = await OperationDao.get_inbound_for_update(query_db, page_object.inbound_id) if inbound is None: raise ServiceException(message='入库单不存在') await AccessService.assert_warehouse_access( query_db, current_user, inbound.warehouse_id, ) if inbound.status != DocumentStatus.DRAFT.value: raise ServiceException(message='已确认入库单不允许编辑') await cls._validate_active_warehouse( query_db, page_object.warehouse_id, current_user, ) ordered_ids = sorted(page_object.pallet_ids) await cls._lock_and_validate_pallets(query_db, ordered_ids) current_time = datetime.now() inbound.warehouse_id = page_object.warehouse_id inbound.update_by = user_name inbound.update_time = current_time await OperationDao.update_inbound(query_db, inbound) await OperationDao.replace_inbound_items( query_db, inbound.inbound_id, cls._build_items(inbound.inbound_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_inbound_services( cls, query_db: AsyncSession, inbound_ids: list[int], current_user: CurrentUserModel, ) -> CrudResponseModel: try: if not inbound_ids or any(inbound_id <= 0 for inbound_id in inbound_ids): raise ServiceException(message='入库单ID必须为正整数') if len(inbound_ids) != len(set(inbound_ids)): raise ServiceException(message='入库单ID不能重复') ordered_ids = sorted(inbound_ids) for inbound_id in ordered_ids: inbound = await OperationDao.get_inbound_for_update(query_db, inbound_id) if inbound is None: raise ServiceException(message='入库单不存在') await AccessService.assert_warehouse_access( query_db, current_user, inbound.warehouse_id, ) if inbound.status != DocumentStatus.DRAFT.value: raise ServiceException(message='已确认入库单不允许删除') for inbound_id in ordered_ids: await OperationDao.delete_inbound(query_db, inbound_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( inbound: BizInboundOrder, inbound_id: int, idempotency_key: str, request_hash: str, ) -> bool: return ( inbound.inbound_id == inbound_id and inbound.status == DocumentStatus.CONFIRMED.value and inbound.confirm_idempotency_key == idempotency_key and inbound.confirm_request_hash == request_hash ) @classmethod async def _recover_confirm_integrity_error( cls, query_db: AsyncSession, inbound_id: int, idempotency_key: str, request_hash: str, current_user: CurrentUserModel, ) -> CrudResponseModel: try: recovered = await OperationDao.get_inbound_by_confirm_idempotency_key( query_db, idempotency_key, ) if recovered is None: raise ServiceException(message='确认入库失败,幂等冲突') await AccessService.assert_warehouse_access( query_db, current_user, recovered.warehouse_id, ) if not cls._is_exact_confirmed( recovered, inbound_id, idempotency_key, request_hash, ): raise ServiceException(message='确认入库失败,幂等键已被其他请求使用') return cls._success('确认成功') finally: await query_db.rollback() @classmethod async def confirm_inbound_services( # noqa: PLR0912, PLR0915 cls, query_db: AsyncSession, inbound_id: int, page_object: ConfirmCommandModel, current_user: CurrentUserModel, ) -> CrudResponseModel: try: user_id, user_name = cls._current_identity(current_user) inbound = await OperationDao.get_inbound_by_id(query_db, inbound_id) if inbound is None: raise ServiceException(message='入库单不存在') await AccessService.assert_warehouse_access( query_db, current_user, inbound.warehouse_id, ) request_hash = cls._build_confirm_request_hash(inbound_id) idempotency_key = page_object.idempotency_key existing = await OperationDao.get_inbound_by_confirm_idempotency_key( query_db, idempotency_key, ) if existing is not None: await AccessService.assert_warehouse_access( query_db, current_user, existing.warehouse_id, ) if not cls._is_exact_confirmed( existing, inbound_id, idempotency_key, request_hash, ): raise ServiceException(message='确认入库失败,幂等键已被其他请求使用') locked_inbound = await OperationDao.get_inbound_for_update(query_db, inbound_id) if locked_inbound is None: raise ServiceException(message='入库单不存在') await AccessService.assert_warehouse_access( query_db, current_user, locked_inbound.warehouse_id, ) if locked_inbound.status == DocumentStatus.CONFIRMED.value: if cls._is_exact_confirmed( locked_inbound, inbound_id, idempotency_key, request_hash, ): await query_db.rollback() return cls._success('确认成功') raise ServiceException(message='入库单已确认,幂等信息不匹配') if locked_inbound.status != DocumentStatus.DRAFT.value: raise ServiceException(message='当前入库单状态不允许确认') if existing is not None: raise ServiceException(message='确认入库失败,并发状态不一致') if locked_inbound.confirm_idempotency_key is not None or locked_inbound.confirm_request_hash is not None: raise ServiceException(message='入库单确认信息不完整') pallet_ids = [item.pallet_id for item in locked_inbound.items] if not pallet_ids: raise ServiceException(message='入库单至少需要一个托盘') if len(pallet_ids) != len(set(pallet_ids)): raise ServiceException(message='入库单托盘不能重复') ordered_ids = sorted(pallet_ids) inventories, _ = await cls._lock_and_validate_pallets(query_db, ordered_ids) for inventory in inventories: PalletStateMachine.ensure_transition( PalletFlowState(inventory.flow_state), PalletFlowState.EMPTY_IN_WAREHOUSE, ) current_time = datetime.now() locked_inbound.status = DocumentStatus.CONFIRMED.value locked_inbound.confirm_idempotency_key = idempotency_key locked_inbound.confirm_request_hash = request_hash locked_inbound.confirmed_by = user_id locked_inbound.confirmed_at = current_time locked_inbound.update_by = user_name locked_inbound.update_time = current_time await OperationDao.update_inbound(query_db, locked_inbound) 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.EMPTY_IN_WAREHOUSE.value inventory.warehouse_id = locked_inbound.warehouse_id inventory.site_id = None inventory.current_line_id = None inventory.active_filling_id = None inventory.remaining_packages = 0 inventory.version += 1 inventory.updated_at = current_time event = BizPalletEvent( pallet_id=inventory.pallet_id, event_type=PalletEventType.INBOUND_CONFIRMED.value, source_type='INBOUND', source_id=inbound_id, before_state=before_state, after_state=PalletFlowState.EMPTY_IN_WAREHOUSE.value, before_warehouse_id=before_warehouse_id, before_site_id=before_site_id, before_line_id=before_line_id, after_warehouse_id=locked_inbound.warehouse_id, after_site_id=None, after_line_id=None, before_remaining_packages=before_remaining_packages, after_remaining_packages=0, 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, inbound_id, page_object.idempotency_key, cls._build_confirm_request_hash(inbound_id), current_user, ) except Exception: await query_db.rollback() raise