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 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, BizStrain from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory from module_business.entity.vo.master_vo import StrainPageQueryModel from module_business.entity.vo.operation_vo import ( BusinessOptionModel, FillingCreateModel, FillingModel, FillingPageQueryModel, PalletScanResponseModel, ) from module_business.service.access_service import AccessService from module_business.service.test_batch_access_service import TestBatchAccessService class FillingService: """仓库内单托盘即时装填服务层。""" @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_request_hash(page_object: FillingCreateModel) -> str: payload = json.dumps( { 'qr_token': page_object.qr_token.strip(), 'warehouse_id': page_object.warehouse_id, 'strain_id': page_object.strain_id, 'batch_no': page_object.batch_no.strip(), 'package_spec': page_object.package_spec.strip(), 'initial_packages': page_object.initial_packages, }, 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_filling_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 _assert_pallet_warehouse_access( cls, query_db: AsyncSession, current_user: CurrentUserModel, warehouse_id: int, *, for_update: bool = False, ) -> None: try: if for_update: await AccessService.assert_warehouse_access( query_db, current_user, warehouse_id, for_update=True, ) else: await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id) except PermissionException: if cls._is_admin(current_user): raise cls._raise_pallet_not_accessible(current_user) @staticmethod def _validate_empty_warehouse_pallet( pallet: BizPallet, inventory: BizPalletInventory, warehouse_id: int, qr_token: str, ) -> None: if pallet.pallet_id != inventory.pallet_id or pallet.qr_token != qr_token: raise ServiceException(message='托盘二维码与库存不一致') if pallet.asset_status != '0': raise ServiceException(message='托盘资产已停用,不允许装填') if ( inventory.flow_state != PalletFlowState.EMPTY_IN_WAREHOUSE.value or inventory.warehouse_id != warehouse_id 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 type(inventory.version) is not int or inventory.version < 0: raise ServiceException(message='托盘库存版本无效') @staticmethod def _validate_package_count(initial_packages: int, pallet: BizPallet) -> None: if type(initial_packages) is not int or initial_packages <= 0: raise ServiceException(message='装填包数必须为正整数') if initial_packages > pallet.capacity_packages: raise ServiceException(message='装填包数不能超过托盘容量') @staticmethod def _validate_active_strain(strain: BizStrain | None) -> BizStrain: if strain is None: raise ServiceException(message='菌种不存在') if strain.status != '0': raise ServiceException(message='菌种已停用') return strain @staticmethod def _is_exact_idempotent_result( filling: BizFillingRecord, page_object: FillingCreateModel, request_hash: str, ) -> bool: return ( filling.idempotency_key == page_object.idempotency_key and filling.warehouse_id == page_object.warehouse_id and filling.request_hash == request_hash ) @staticmethod def _success(filling_id: int) -> CrudResponseModel: return CrudResponseModel(is_success=True, message='装填成功', result=filling_id) @classmethod async def get_filling_list_services( cls, query_db: AsyncSession, query_object: FillingPageQueryModel, current_user: CurrentUserModel, test_scope: TestBatchScope, is_page: bool = True, ) -> PageModel | list[FillingModel]: 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_filling_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 = [FillingModel.model_validate(record) for record in rows] if isinstance(result, PageModel): return result.model_copy(update={'rows': models}) return models @classmethod async def filling_detail_services( cls, query_db: AsyncSession, filling_id: int, current_user: CurrentUserModel, test_scope: TestBatchScope, ) -> FillingModel: warehouse_ids = await cls._warehouse_scope(query_db, current_user) filling = await OperationDao.get_filling_by_id( query_db, filling_id, test_scope, warehouse_ids=warehouse_ids, ) if filling is None: cls._raise_filling_not_accessible(current_user) return FillingModel.model_validate(filling) @classmethod async def get_strain_options_services( cls, query_db: AsyncSession, current_user: CurrentUserModel, test_scope: TestBatchScope, ) -> list[BusinessOptionModel]: cls._current_identity(current_user) strains = await MasterDao.list_strain( query_db, StrainPageQueryModel(status='0'), test_scope, False, ) options = [ BusinessOptionModel( id=cls._record_value(strain, 'strain_id'), code=cls._record_value(strain, 'strain_code'), name=cls._record_value(strain, 'strain_name'), ) for strain in strains if cls._record_value(strain, 'status') == '0' ] return sorted(options, key=lambda option: option.id) @classmethod async def scan_filling_pallet_services( cls, query_db: AsyncSession, qr_token: str, current_user: CurrentUserModel, test_scope: TestBatchScope, ) -> PalletScanResponseModel: pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope) if pallet is None: cls._raise_pallet_not_accessible(current_user) inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') if inventory.warehouse_id is None: cls._raise_pallet_not_accessible(current_user, '托盘不在仓库') await cls._assert_pallet_warehouse_access( query_db, current_user, inventory.warehouse_id, ) cls._validate_empty_warehouse_pallet( pallet, inventory, inventory.warehouse_id, qr_token, ) return PalletScanResponseModel( pallet_id=pallet.pallet_id, pallet_code=pallet.pallet_code, flow_state=PalletFlowState.EMPTY_IN_WAREHOUSE, warehouse_id=inventory.warehouse_id, 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=['filling'], ) @classmethod async def _recover_integrity_error( cls, query_db: AsyncSession, page_object: FillingCreateModel, 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) if warehouse_ids is not None and page_object.warehouse_id not in warehouse_ids: raise PermissionException(data='', message='无权访问该仓库') recovered = await OperationDao.get_filling_by_idempotency_key( query_db, page_object.idempotency_key, test_scope, warehouse_ids=[page_object.warehouse_id], ) if recovered is None: raise ServiceException(message='装填失败,数据冲突') await AccessService.assert_warehouse_access( query_db, current_user, recovered.warehouse_id, ) if not cls._is_exact_idempotent_result(recovered, page_object, request_hash): raise ServiceException(message='装填失败,幂等键已被其他请求使用') return cls._success(recovered.filling_id) finally: await query_db.rollback() @classmethod async def fill_pallet_services( # noqa: PLR0912, PLR0915 cls, query_db: AsyncSession, page_object: FillingCreateModel, current_user: CurrentUserModel, test_scope: TestBatchScope, ) -> CrudResponseModel: recovery_transaction_ended = False try: await TestBatchAccessService.lock_active_batch(query_db, test_scope) request_hash = cls._build_request_hash(page_object) user_id, user_name = cls._current_identity(current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user) if warehouse_ids is not None and page_object.warehouse_id not in warehouse_ids: raise PermissionException(data='', message='无权访问该仓库') existing = await OperationDao.get_filling_by_idempotency_key( query_db, page_object.idempotency_key, test_scope, warehouse_ids=[page_object.warehouse_id], ) if existing is not None: await AccessService.assert_warehouse_access( query_db, current_user, existing.warehouse_id, ) if not cls._is_exact_idempotent_result(existing, page_object, request_hash): raise ServiceException(message='装填失败,幂等键已被其他请求使用') filling_id = existing.filling_id await query_db.rollback() return cls._success(filling_id) await AccessService.assert_warehouse_access( query_db, current_user, page_object.warehouse_id, ) scanned_pallet = await InventoryDao.get_pallet_by_qr_token( query_db, page_object.qr_token, test_scope, ) if scanned_pallet is None: cls._raise_pallet_not_accessible(current_user) inventory = await InventoryDao.get_inventory_for_update( query_db, scanned_pallet.pallet_id, test_scope, ) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') if inventory.warehouse_id is None: cls._raise_pallet_not_accessible(current_user, '托盘不在仓库') await cls._assert_pallet_warehouse_access( query_db, current_user, inventory.warehouse_id, ) locked_pallets = await InventoryDao.get_pallets_by_ids( query_db, [scanned_pallet.pallet_id], test_scope, ) if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != scanned_pallet.pallet_id: cls._raise_pallet_not_accessible(current_user) pallet = locked_pallets[0] if inventory.active_filling_id is not None: concurrent = await OperationDao.get_active_filling_for_update( query_db, inventory.active_filling_id, test_scope, ) if concurrent is not None and concurrent.idempotency_key == page_object.idempotency_key: # 结束首次非锁定读取建立的 RR 快照,再按最新数据范围恢复并发成功结果。 await query_db.rollback() recovery_transaction_ended = True return await cls._recover_integrity_error( query_db, page_object, request_hash, current_user, test_scope, ) warehouse = await MasterDao.get_warehouse( query_db, inventory.warehouse_id, test_scope, for_update=True, ) if warehouse is None: raise ServiceException(message='仓库不存在') if warehouse.status != '0': raise ServiceException(message='仓库已停用') await cls._assert_pallet_warehouse_access( query_db, current_user, inventory.warehouse_id, for_update=True, ) strain = cls._validate_active_strain( await MasterDao.get_strain( query_db, page_object.strain_id, test_scope, for_update=True, ) ) cls._validate_empty_warehouse_pallet( pallet, inventory, page_object.warehouse_id, page_object.qr_token, ) cls._validate_package_count(page_object.initial_packages, pallet) PalletStateMachine.ensure_transition( PalletFlowState(inventory.flow_state), PalletFlowState.FILLED_IN_WAREHOUSE, ) current_time = datetime.now() filling = BizFillingRecord( filling_no=f'FL-{secrets.token_hex(16)}', pallet_id=pallet.pallet_id, warehouse_id=inventory.warehouse_id, strain_id=strain.strain_id, strain_code_snapshot=strain.strain_code, strain_name_snapshot=strain.strain_name, batch_no=page_object.batch_no.strip(), package_spec=page_object.package_spec.strip(), initial_packages=page_object.initial_packages, active_slot='ACTIVE', idempotency_key=page_object.idempotency_key, request_hash=request_hash, operator_id=user_id, operated_at=current_time, create_by=user_name, create_time=current_time, update_by=user_name, update_time=current_time, test_batch_id=test_scope.batch_id, ) filling = await OperationDao.add_filling_record(query_db, filling) filling_id = filling.filling_id 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.active_filling_id = filling_id inventory.remaining_packages = page_object.initial_packages inventory.flow_state = PalletFlowState.FILLED_IN_WAREHOUSE.value inventory.version += 1 inventory.updated_at = current_time event = BizPalletEvent( pallet_id=inventory.pallet_id, event_type=PalletEventType.FILLING_CONFIRMED.value, source_type='FILLING', source_id=filling_id, before_state=before_state, after_state=PalletFlowState.FILLED_IN_WAREHOUSE.value, before_warehouse_id=before_warehouse_id, before_site_id=before_site_id, before_line_id=before_line_id, after_warehouse_id=inventory.warehouse_id, after_site_id=inventory.site_id, after_line_id=inventory.current_line_id, before_remaining_packages=before_remaining_packages, after_remaining_packages=page_object.initial_packages, operator_id=user_id, occurred_at=current_time, idempotency_key=page_object.idempotency_key, request_hash=request_hash, test_batch_id=test_scope.batch_id, ) await InventoryDao.add_event(query_db, inventory, event, test_scope) await query_db.commit() return cls._success(filling_id) except IntegrityError: await query_db.rollback() return await cls._recover_integrity_error( query_db, page_object, request_hash, current_user, test_scope, ) except Exception: if not recovery_transaction_ended: await query_db.rollback() raise