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, ServiceWarning 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.domain.test_batch_scope import TestBatchScope 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 from module_business.service.test_batch_access_service import TestBatchAccessService 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))) @classmethod def _raise_inbound_not_accessible( cls, current_user: CurrentUserModel, ) -> NoReturn: if cls._is_admin(current_user): raise ServiceException(message='入库单不存在') raise PermissionException(data='', message='无权访问该入库单') @staticmethod async def _validate_active_warehouse( query_db: AsyncSession, warehouse_id: int, current_user: CurrentUserModel, test_scope: TestBatchScope, ) -> Any: await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id) warehouse = await MasterDao.get_warehouse(query_db, warehouse_id, test_scope) if warehouse is None: raise ServiceException(message='目标仓库不存在') if warehouse.status != '0': raise ServiceException(message='目标仓库已停用') return warehouse @classmethod async def _lock_and_authorize_warehouses( cls, query_db: AsyncSession, current_user: CurrentUserModel, *, warehouse_ids: set[int], hidden_warehouse_ids: set[int], require_active: bool, test_scope: TestBatchScope, ) -> None: """锁定仓库及授权映射,并基于锁后最新状态重新校验写权限。""" warehouses: dict[int, Any] = {} permission_errors: dict[int, PermissionException] = {} for warehouse_id in sorted(warehouse_ids): warehouses[warehouse_id] = await MasterDao.get_warehouse( query_db, warehouse_id, test_scope, for_update=True, ) try: await AccessService.assert_warehouse_access( query_db, current_user, warehouse_id, for_update=True, ) except PermissionException as exc: permission_errors[warehouse_id] = exc if hidden_warehouse_ids & permission_errors.keys(): cls._raise_inbound_not_accessible(current_user) if permission_errors: raise permission_errors[min(permission_errors)] 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='目标仓库已停用') @classmethod async def _lock_and_reauthorize_warehouse( cls, query_db: AsyncSession, inbound: BizInboundOrder, current_user: CurrentUserModel, *, test_scope: TestBatchScope, require_active: bool = True, ) -> None: await cls._lock_and_authorize_warehouses( query_db, current_user, warehouse_ids={inbound.warehouse_id}, hidden_warehouse_ids={inbound.warehouse_id}, require_active=require_active, test_scope=test_scope, ) @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], test_scope: TestBatchScope, ) -> tuple[list[BizPalletInventory], list[BizPallet]]: ordered_ids = sorted(pallet_ids) inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids, test_scope) pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids, test_scope) 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, test_batch_id: int | None, ) -> list[BizInboundItem]: return [ BizInboundItem( inbound_id=inbound_id, pallet_id=pallet_id, create_by=user_name, create_time=current_time, test_batch_id=test_batch_id, ) 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, test_scope: TestBatchScope, 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, warehouse_ids=warehouse_ids, scope=test_scope, is_page=is_page, ) 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, test_scope: TestBatchScope, ) -> InboundModel: warehouse_ids = await cls._warehouse_scope(query_db, current_user) inbound = await OperationDao.get_inbound_by_id( query_db, inbound_id, test_scope, warehouse_ids=warehouse_ids, ) if inbound is None: cls._raise_inbound_not_accessible(current_user) return InboundModel.model_validate(inbound) @classmethod async def get_warehouse_options_services( cls, query_db: AsyncSession, current_user: CurrentUserModel, test_scope: TestBatchScope, ) -> 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'), test_scope, 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, test_scope: TestBatchScope, ) -> 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, test_scope) 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, test_scope) if pallet is None: raise ServiceException(message='托盘不存在') inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) 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, test_scope: TestBatchScope, ) -> CrudResponseModel: try: await TestBatchAccessService.lock_active_batch(query_db, test_scope) _, user_name = cls._current_identity(current_user) await cls._validate_active_warehouse( query_db, page_object.warehouse_id, current_user, test_scope, ) ordered_ids = sorted(page_object.pallet_ids) await cls._lock_and_validate_pallets(query_db, ordered_ids, test_scope) await cls._lock_and_authorize_warehouses( query_db, current_user, warehouse_ids={page_object.warehouse_id}, hidden_warehouse_ids=set(), require_active=True, test_scope=test_scope, ) 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, test_batch_id=test_scope.batch_id, ) 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, test_scope.batch_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 @classmethod async def edit_inbound_services( cls, query_db: AsyncSession, page_object: InboundUpdateModel, current_user: CurrentUserModel, test_scope: TestBatchScope, ) -> CrudResponseModel: try: await TestBatchAccessService.lock_active_batch(query_db, test_scope) _, user_name = cls._current_identity(current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user) inbound = await OperationDao.get_inbound_for_update( query_db, page_object.inbound_id, test_scope, warehouse_ids=warehouse_ids, ) if inbound is None: cls._raise_inbound_not_accessible(current_user) if inbound.status != DocumentStatus.DRAFT.value: raise ServiceException(message='已确认入库单不允许编辑') await cls._validate_active_warehouse( query_db, page_object.warehouse_id, current_user, test_scope, ) ordered_ids = sorted(page_object.pallet_ids) await cls._lock_and_validate_pallets(query_db, ordered_ids, test_scope) await cls._lock_and_authorize_warehouses( query_db, current_user, warehouse_ids={inbound.warehouse_id, page_object.warehouse_id}, hidden_warehouse_ids={inbound.warehouse_id}, require_active=True, test_scope=test_scope, ) 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, test_scope.batch_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 @classmethod async def delete_inbound_services( cls, query_db: AsyncSession, inbound_ids: list[int], current_user: CurrentUserModel, test_scope: TestBatchScope, ) -> CrudResponseModel: raise ServiceWarning(message='不支持单条物理删除,请通过测试数据批次清理') @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, test_scope: TestBatchScope, ) -> CrudResponseModel: try: await TestBatchAccessService.lock_active_batch(query_db, test_scope) warehouse_ids = await cls._warehouse_scope(query_db, current_user) current = await OperationDao.get_inbound_by_id( query_db, inbound_id, test_scope, warehouse_ids=warehouse_ids, ) if current is None: cls._raise_inbound_not_accessible(current_user) recovered = await OperationDao.get_inbound_by_confirm_idempotency_key( query_db, idempotency_key, test_scope, warehouse_ids=[current.warehouse_id], ) if recovered is None: raise ServiceException(message='确认入库失败,幂等冲突') if not cls._is_exact_confirmed( recovered, inbound_id, idempotency_key, request_hash, ): raise ServiceException(message='确认入库失败,幂等键已被其他请求使用') locked_recovered = await OperationDao.get_inbound_for_update( query_db, inbound_id, test_scope, warehouse_ids=warehouse_ids, ) if locked_recovered is None: cls._raise_inbound_not_accessible(current_user) if not cls._is_exact_confirmed( locked_recovered, inbound_id, idempotency_key, request_hash, ): raise ServiceException(message='确认入库失败,幂等键已被其他请求使用') await cls._lock_and_reauthorize_warehouse( query_db, locked_recovered, current_user, test_scope=test_scope, require_active=False, ) 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, test_scope: TestBatchScope, ) -> CrudResponseModel: try: await TestBatchAccessService.lock_active_batch(query_db, test_scope) user_id, user_name = cls._current_identity(current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user) inbound = await OperationDao.get_inbound_by_id( query_db, inbound_id, test_scope, warehouse_ids=warehouse_ids, ) if inbound is None: cls._raise_inbound_not_accessible(current_user) 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, test_scope, warehouse_ids=[inbound.warehouse_id], ) 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, test_scope, warehouse_ids=warehouse_ids, ) if locked_inbound is None: cls._raise_inbound_not_accessible(current_user) if locked_inbound.status == DocumentStatus.CONFIRMED.value: if cls._is_exact_confirmed( locked_inbound, inbound_id, idempotency_key, request_hash, ): await cls._lock_and_reauthorize_warehouse( query_db, locked_inbound, current_user, test_scope=test_scope, require_active=False, ) 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, test_scope) await cls._lock_and_reauthorize_warehouse( query_db, locked_inbound, current_user, test_scope=test_scope, ) 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, test_batch_id=test_scope.batch_id, ) await InventoryDao.add_event(query_db, inventory, event, test_scope) 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, test_scope, ) except Exception: await query_db.rollback() raise