"""客户现场使用服务。""" 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.entity.do.operation_do import BizPalletEvent, BizUsageRecord from module_business.entity.vo.master_vo import LinePageQueryModel from module_business.entity.vo.operation_vo import ( BusinessOptionModel, PalletScanResponseModel, UsageCreateModel, UsageModel, UsagePageQueryModel, ) from module_business.service.access_service import AccessService class UsageService: """客户现场使用服务层。""" @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) @classmethod async def _site_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_site_ids(query_db, user_id))) @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_site_access( cls, query_db: AsyncSession, current_user: CurrentUserModel, site_id: int, *, for_update: bool = False, ) -> None: try: if for_update: await AccessService.assert_site_access( query_db, current_user, site_id, for_update=True, ) else: await AccessService.assert_site_access(query_db, current_user, site_id) except PermissionException: if cls._is_admin(current_user): raise cls._raise_pallet_not_accessible(current_user) @classmethod def _raise_usage_not_accessible(cls, current_user: CurrentUserModel) -> NoReturn: if cls._is_admin(current_user): raise ServiceException(message='使用记录不存在') raise PermissionException(data='', message='无权访问该使用记录') @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: UsageCreateModel) -> str: payload = json.dumps( { 'qr_token': page_object.qr_token.strip(), 'line_id': page_object.line_id, 'used_packages': page_object.used_packages, }, sort_keys=True, separators=(',', ':'), ).encode() return hashlib.sha256(payload).hexdigest() @staticmethod def _is_exact_idempotent_result( usage: BizUsageRecord, page_object: UsageCreateModel, request_hash: str, ) -> bool: return usage.idempotency_key == page_object.idempotency_key and usage.request_hash == request_hash @staticmethod def _success(usage_id: int) -> CrudResponseModel: return CrudResponseModel(is_success=True, message='使用成功', result=usage_id) @classmethod async def _lock_and_reauthorize_usage_site( cls, query_db: AsyncSession, site_id: int, current_user: CurrentUserModel, ) -> None: site = await MasterDao.get_site(query_db, site_id, for_update=True) try: await AccessService.assert_site_access( query_db, current_user, site_id, for_update=True, ) except PermissionException: if cls._is_admin(current_user): raise cls._raise_usage_not_accessible(current_user) if site is None: raise ServiceException(message='客户现场不存在') @classmethod async def _recover_integrity_error( cls, query_db: AsyncSession, page_object: UsageCreateModel, request_hash: str, current_user: CurrentUserModel, expected_site_id: int, ) -> CrudResponseModel: try: site_ids = await cls._site_scope(query_db, current_user) if site_ids is not None and expected_site_id not in site_ids: cls._raise_pallet_not_accessible(current_user) recovered = await OperationDao.get_usage_by_idempotency_key( query_db, page_object.idempotency_key, site_ids=site_ids, for_update=False, ) if recovered is None: raise ServiceException(message='使用失败,数据冲突') if recovered.site_id != expected_site_id or not cls._is_exact_idempotent_result( recovered, page_object, request_hash ): raise ServiceException(message='使用失败,幂等键已被其他请求使用') await cls._lock_and_reauthorize_usage_site( query_db, recovered.site_id, current_user, ) return cls._success(recovered.usage_id) finally: await query_db.rollback() @classmethod async def get_usage_list_services( cls, query_db: AsyncSession, query_object: UsagePageQueryModel, current_user: CurrentUserModel, is_page: bool = True, ) -> PageModel | list[UsageModel]: site_ids = await cls._site_scope(query_db, current_user) if site_ids is not None and query_object.site_id is not None and query_object.site_id not in site_ids: raise PermissionException(data='', message='无权访问该客户现场') if 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_usage_list( query_db, query_object, is_page, site_ids=site_ids, ) rows = result.rows if isinstance(result, PageModel) else result models = [UsageModel.model_validate(record) for record in rows] if isinstance(result, PageModel): return result.model_copy(update={'rows': models}) return models @classmethod async def usage_detail_services( cls, query_db: AsyncSession, usage_id: int, current_user: CurrentUserModel, ) -> UsageModel: site_ids = await cls._site_scope(query_db, current_user) usage = await OperationDao.get_usage_by_id( query_db, usage_id, site_ids=site_ids, ) if usage is None: cls._raise_usage_not_accessible(current_user) return UsageModel.model_validate(usage) @classmethod async def get_line_options_services( cls, query_db: AsyncSession, site_id: int, current_user: CurrentUserModel, ) -> list[BusinessOptionModel]: cls._current_identity(current_user) await AccessService.assert_site_access(query_db, current_user, site_id) site = await MasterDao.get_site(query_db, site_id) if site is None: raise ServiceException(message='客户现场不存在') if site.status != '0': raise ServiceException(message='客户现场已停用') lines = await MasterDao.list_line( query_db, LinePageQueryModel(siteId=site_id, status='0'), False, ) options = [ BusinessOptionModel( id=cls._record_value(line, 'line_id'), code=cls._record_value(line, 'line_code'), name=cls._record_value(line, 'line_name'), ) for line in lines if cls._record_value(line, 'site_id') == site_id and cls._record_value(line, 'status') == '0' ] return sorted(options, key=lambda option: option.id) @classmethod async def scan_usage_pallet_services( cls, query_db: AsyncSession, qr_token: str, current_user: CurrentUserModel, ) -> PalletScanResponseModel: normalized_qr = qr_token.strip() pallet = await InventoryDao.get_pallet_by_qr_token(query_db, normalized_qr) 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.site_id is None: cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场') await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id) if pallet.qr_token != normalized_qr or pallet.pallet_id != inventory.pallet_id: raise ServiceException(message='托盘二维码与库存不一致') if pallet.asset_status != '0': raise ServiceException(message='托盘资产已停用,不允许使用') if ( inventory.flow_state not in { PalletFlowState.AT_SITE_UNUSED.value, PalletFlowState.AT_SITE_PARTIAL.value, } or inventory.warehouse_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='托盘当前状态不允许使用') filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id) if filling is None: raise ServiceException(message='托盘活动装填周期不存在') if ( filling.filling_id != inventory.active_filling_id or filling.pallet_id != pallet.pallet_id or filling.active_slot != 'ACTIVE' or filling.closed_at is not None ): raise ServiceException(message='托盘活动装填周期无效') return PalletScanResponseModel( pallet_id=pallet.pallet_id, pallet_code=pallet.pallet_code, flow_state=PalletFlowState(inventory.flow_state), warehouse_id=None, site_id=inventory.site_id, current_line_id=inventory.current_line_id, 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=['usage'], ) @classmethod async def record_usage_services( # noqa: PLR0912, PLR0915 cls, query_db: AsyncSession, page_object: UsageCreateModel, current_user: CurrentUserModel, ) -> CrudResponseModel: expected_site_id: int | None = None try: request_hash = cls._build_request_hash(page_object) user_id, user_name = cls._current_identity(current_user) site_ids = await cls._site_scope(query_db, current_user) existing = await OperationDao.get_usage_by_idempotency_key( query_db, page_object.idempotency_key, site_ids=site_ids, for_update=False, ) if existing is not None: if not cls._is_exact_idempotent_result(existing, page_object, request_hash): raise ServiceException(message='使用失败,幂等键已被其他请求使用') if site_ids is not None and existing.site_id not in site_ids: cls._raise_usage_not_accessible(current_user) await cls._lock_and_reauthorize_usage_site( query_db, existing.site_id, current_user, ) usage_id = existing.usage_id await query_db.rollback() return cls._success(usage_id) scanned_pallet = await InventoryDao.get_pallet_by_qr_token( query_db, page_object.qr_token.strip(), ) 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, ) if inventory is None: cls._raise_pallet_not_accessible(current_user, '托盘库存不存在') if inventory.site_id is None: cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场') expected_site_id = inventory.site_id await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id) locked_pallets = await InventoryDao.get_pallets_by_ids( query_db, [inventory.pallet_id], ) if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != inventory.pallet_id: cls._raise_pallet_not_accessible(current_user) pallet = locked_pallets[0] filling = ( await OperationDao.get_active_filling_for_update( query_db, inventory.active_filling_id, ) if inventory.active_filling_id is not None else None ) locked_existing = await OperationDao.get_usage_by_idempotency_key( query_db, page_object.idempotency_key, site_ids=site_ids, for_update=True, ) if locked_existing is not None: if locked_existing.site_id != inventory.site_id or not cls._is_exact_idempotent_result( locked_existing, page_object, request_hash, ): raise ServiceException(message='使用失败,幂等键已被其他请求使用') await cls._lock_and_reauthorize_usage_site( query_db, locked_existing.site_id, current_user, ) usage_id = locked_existing.usage_id await query_db.rollback() return cls._success(usage_id) if inventory.active_filling_id is None: raise ServiceException(message='托盘不存在活动装填周期') if filling is None: raise ServiceException(message='托盘活动装填周期不存在或已关闭') site = await MasterDao.get_site(query_db, inventory.site_id, for_update=True) await cls._assert_pallet_site_access( query_db, current_user, inventory.site_id, for_update=True, ) if site is None: raise ServiceException(message='客户现场不存在') if site.status != '0': raise ServiceException(message='客户现场已停用') line = await MasterDao.get_line(query_db, page_object.line_id, for_update=True) if line is None: raise ServiceException(message='产线不存在') if line.status != '0': raise ServiceException(message='产线已停用') if line.site_id != inventory.site_id: raise ServiceException(message='产线不属于托盘当前客户现场') if pallet.pallet_id != inventory.pallet_id or pallet.qr_token != page_object.qr_token.strip(): raise ServiceException(message='托盘二维码与库存不一致') if pallet.asset_status != '0': raise ServiceException(message='托盘资产已停用,不允许使用') if inventory.flow_state not in { PalletFlowState.AT_SITE_UNUSED.value, PalletFlowState.AT_SITE_PARTIAL.value, }: raise ServiceException(message='托盘当前状态不允许使用') if inventory.warehouse_id is not 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' or filling.closed_at is not None ): raise ServiceException(message='托盘活动装填周期无效') if inventory.current_line_id is not None and inventory.current_line_id != line.line_id: raise ServiceException(message='托盘本次驻场周期已绑定其他产线') if type(page_object.used_packages) is not int or page_object.used_packages <= 0: raise ServiceException(message='使用包数必须为正整数') if page_object.used_packages > inventory.remaining_packages: raise ServiceException(message='使用包数不能超过当前余量') remaining_after = inventory.remaining_packages - page_object.used_packages target_state = PalletFlowState.AT_SITE_EMPTY if remaining_after == 0 else PalletFlowState.AT_SITE_PARTIAL PalletStateMachine.ensure_transition( PalletFlowState(inventory.flow_state), target_state, ) current_time = datetime.now() usage = BizUsageRecord( usage_no=f'US-{secrets.token_hex(16)}', pallet_id=pallet.pallet_id, active_filling_id=filling.filling_id, site_id=inventory.site_id, line_id=line.line_id, used_packages=page_object.used_packages, remaining_packages_after=remaining_after, idempotency_key=page_object.idempotency_key, request_hash=request_hash, operator_id=user_id, occurred_at=current_time, ) usage = await OperationDao.add_usage_record(query_db, usage) usage_id = usage.usage_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.current_line_id = line.line_id inventory.remaining_packages = remaining_after inventory.flow_state = target_state.value if target_state is PalletFlowState.AT_SITE_EMPTY: filling.active_slot = None filling.closed_at = current_time filling.update_by = user_name filling.update_time = current_time await OperationDao.update_filling_record(query_db, filling) inventory.active_filling_id = None inventory.version += 1 inventory.updated_at = current_time event = BizPalletEvent( pallet_id=inventory.pallet_id, event_type=PalletEventType.USAGE_RECORDED.value, source_type='USAGE', source_id=usage_id, before_state=before_state, after_state=target_state.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=remaining_after, operator_id=user_id, occurred_at=current_time, idempotency_key=page_object.idempotency_key, request_hash=request_hash, ) await InventoryDao.add_event(query_db, inventory, event) await query_db.commit() return cls._success(usage_id) except IntegrityError: await query_db.rollback() if expected_site_id is None: raise ServiceException(message='使用失败,数据冲突') from None return await cls._recover_integrity_error( query_db, page_object, request_hash, current_user, expected_site_id, ) except Exception: await query_db.rollback() raise