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 from exceptions.exception import PermissionException, ServiceException, ServiceWarning from module_admin.entity.vo.user_vo import CurrentUserModel from module_business.dao.test_batch_dao import TestBatchDao from module_business.domain.enums import TestBatchStatus from module_business.entity.do.test_batch_do import BizTestBatch from module_business.entity.vo.test_batch_vo import ( BatchImpactModel, TestBatchCreateModel, TestBatchDetailModel, TestBatchEditModel, TestBatchModel, TestBatchOptionModel, TestBatchUserAssignModel, ) from module_business.service.test_batch_access_service import TestBatchAccessService _OWNED_TABLE_LABELS: tuple[tuple[str, str], ...] = ( ('biz_strain', '菌种'), ('biz_customer', '客户'), ('biz_customer_site', '客户现场'), ('biz_production_line', '生产线'), ('biz_warehouse', '仓库'), ('biz_pallet', '托盘'), ('biz_inbound_order', '入库单'), ('biz_inbound_item', '入库明细'), ('biz_filling_record', '装填记录'), ('biz_shipment_order', '发货单'), ('biz_shipment_item', '发货明细'), ('biz_usage_record', '使用记录'), ('biz_return_order', '退回单'), ('biz_return_item', '退回明细'), ('biz_pallet_inventory', '托盘库存'), ('biz_pallet_event', '托盘事件'), ) class TestBatchService: """测试数据批次生命周期与用户授权服务。""" @staticmethod def _assert_admin(current_user: CurrentUserModel) -> None: TestBatchAccessService.assert_super_admin(current_user) @staticmethod def _user_identity(current_user: CurrentUserModel) -> tuple[int, str]: user = current_user.user if user is None or user.user_id is None or user.user_name is None: raise PermissionException('', '当前用户信息不可用') return user.user_id, user.user_name @staticmethod def _parse_purge_summary(raw_summary: Any) -> dict[str, int] | None: if raw_summary is None: return None try: parsed = json.loads(raw_summary) if isinstance(raw_summary, str) else raw_summary except (TypeError, ValueError): raise ServiceException(message='测试批次清理摘要格式无效') from None if not isinstance(parsed, dict) or any( not isinstance(key, str) or isinstance(value, bool) or not isinstance(value, int) or value < 0 for key, value in parsed.items() ): raise ServiceException(message='测试批次清理摘要格式无效') return parsed @classmethod def _to_batch_model(cls, batch: BizTestBatch | Any) -> TestBatchModel: values = { field_name: getattr(batch, field_name, None) for field_name in TestBatchModel.model_fields } values['purge_summary'] = cls._parse_purge_summary(getattr(batch, 'purge_summary', None)) return TestBatchModel.model_validate(values) @staticmethod def _clear_preview(batch: BizTestBatch | Any) -> None: batch.preview_action = None batch.preview_token_hash = None batch.preview_scope_hash = None batch.preview_expires_at = None @staticmethod def _touch(batch: BizTestBatch | Any, user_name: str, now: datetime) -> None: batch.update_by = user_name batch.update_time = now @staticmethod def _raise_for_non_active(batch: BizTestBatch | Any) -> None: if batch.status == TestBatchStatus.CLOSED.value: raise ServiceWarning(message='测试批次已关闭') if batch.status == TestBatchStatus.PURGED.value: raise ServiceWarning(message='测试批次已清理') if batch.status != TestBatchStatus.ACTIVE.value: raise ServiceWarning(message='测试批次状态异常') @classmethod async def list_available_batches( cls, db: AsyncSession, current_user: CurrentUserModel, ) -> list[TestBatchOptionModel]: user_id, _ = cls._user_identity(current_user) is_admin = current_user.user is not None and current_user.user.admin is True batches = await TestBatchDao.list_available_batches(db, user_id, is_admin) return [TestBatchOptionModel.model_validate(batch) for batch in batches] @classmethod async def list_batches( cls, db: AsyncSession, current_user: CurrentUserModel, ) -> list[TestBatchModel]: cls._assert_admin(current_user) batches = await TestBatchDao.list_batches(db) return [cls._to_batch_model(batch) for batch in batches] @classmethod async def get_batch_detail( cls, db: AsyncSession, batch_id: int, current_user: CurrentUserModel, ) -> TestBatchDetailModel: cls._assert_admin(current_user) batch = await TestBatchDao.get_batch(db, batch_id) if batch is None: raise ServiceWarning(message='测试批次不存在') user_ids = await TestBatchDao.list_batch_user_ids(db, batch_id) counts = await TestBatchDao.count_owned_rows(db, batch_id) data_summary = [ BatchImpactModel( table_name=table_name, label=label, count=counts.get(table_name, 0), keys=[], ) for table_name, label in _OWNED_TABLE_LABELS ] batch_model = cls._to_batch_model(batch) return TestBatchDetailModel( **batch_model.model_dump(), user_ids=user_ids, data_summary=data_summary, ) @classmethod async def create_batch( cls, db: AsyncSession, command: TestBatchCreateModel, current_user: CurrentUserModel, ) -> CrudResponseModel: cls._assert_admin(current_user) _, user_name = cls._user_identity(current_user) batch_name = command.batch_name.strip() if not batch_name: raise ServiceWarning(message='测试批次名称不能为空') now = datetime.now() batch = BizTestBatch( batch_code=f'TB-{now:%Y%m%d%H%M%S}-{secrets.token_hex(4).upper()}', batch_name=batch_name, status=TestBatchStatus.ACTIVE.value, create_by=user_name, create_time=now, update_by=user_name, update_time=now, remark=command.remark, ) try: await TestBatchDao.add_batch(db, batch) await db.commit() return CrudResponseModel(is_success=True, message='测试批次创建成功') except IntegrityError: await db.rollback() raise ServiceException(message='测试批次创建失败,批次编码冲突') from None except Exception: await db.rollback() raise @classmethod async def edit_batch( cls, db: AsyncSession, batch_id: int, command: TestBatchEditModel, current_user: CurrentUserModel, ) -> CrudResponseModel: cls._assert_admin(current_user) _, user_name = cls._user_identity(current_user) try: batch = await TestBatchDao.get_batch_for_update(db, batch_id) if batch is None: raise ServiceWarning(message='测试批次不存在') if batch.status == TestBatchStatus.PURGED.value: raise ServiceWarning(message='测试批次已清理,不能修改') if batch.status not in (TestBatchStatus.ACTIVE.value, TestBatchStatus.CLOSED.value): raise ServiceWarning(message='测试批次状态异常') now = datetime.now() batch.remark = command.remark cls._touch(batch, user_name, now) await db.commit() return CrudResponseModel(is_success=True, message='测试批次修改成功') except IntegrityError: await db.rollback() raise ServiceException(message='测试批次修改失败,数据状态已变化') from None except Exception: await db.rollback() raise @classmethod async def replace_users( cls, db: AsyncSession, batch_id: int, command: TestBatchUserAssignModel, current_user: CurrentUserModel, ) -> CrudResponseModel: cls._assert_admin(current_user) _, user_name = cls._user_identity(current_user) try: batch = await TestBatchDao.get_batch_for_update(db, batch_id) if batch is None: raise ServiceWarning(message='测试批次不存在') cls._raise_for_non_active(batch) user_ids = list(dict.fromkeys(command.user_ids)) if user_ids: active_user_ids = await TestBatchDao.list_active_user_ids(db, user_ids) if set(active_user_ids) != set(user_ids): raise ServiceWarning(message='存在不存在、停用或已删除的用户') now = datetime.now() await TestBatchDao.replace_batch_users(db, batch_id, user_ids, user_name, now) cls._touch(batch, user_name, now) await db.commit() return CrudResponseModel(is_success=True, message='测试批次用户授权成功') except IntegrityError: await db.rollback() raise ServiceException(message='测试批次用户授权失败,关联数据无效') from None except Exception: await db.rollback() raise @classmethod async def close_batch( cls, db: AsyncSession, batch_id: int, current_user: CurrentUserModel, ) -> CrudResponseModel: cls._assert_admin(current_user) user_id, user_name = cls._user_identity(current_user) try: batch = await TestBatchDao.get_batch_for_update(db, batch_id) if batch is None: raise ServiceWarning(message='测试批次不存在') cls._raise_for_non_active(batch) now = datetime.now() batch.status = TestBatchStatus.CLOSED.value batch.closed_by = user_id batch.closed_at = now cls._clear_preview(batch) cls._touch(batch, user_name, now) await db.commit() return CrudResponseModel(is_success=True, message='测试批次关闭成功') except IntegrityError: await db.rollback() raise ServiceException(message='测试批次关闭失败,数据状态已变化') from None except Exception: await db.rollback() raise @classmethod async def reopen_batch( cls, db: AsyncSession, batch_id: int, current_user: CurrentUserModel, ) -> CrudResponseModel: cls._assert_admin(current_user) _, user_name = cls._user_identity(current_user) try: batch = await TestBatchDao.get_batch_for_update(db, batch_id) if batch is None: raise ServiceWarning(message='测试批次不存在') if batch.status == TestBatchStatus.PURGED.value: raise ServiceWarning(message='测试批次已清理,不能重新开启') if batch.status == TestBatchStatus.ACTIVE.value: raise ServiceWarning(message='测试批次已处于启用状态') if batch.status != TestBatchStatus.CLOSED.value: raise ServiceWarning(message='测试批次状态异常') now = datetime.now() batch.status = TestBatchStatus.ACTIVE.value batch.closed_by = None batch.closed_at = None cls._clear_preview(batch) cls._touch(batch, user_name, now) await db.commit() return CrudResponseModel(is_success=True, message='测试批次重新开启成功') except IntegrityError: await db.rollback() raise ServiceException(message='测试批次重新开启失败,数据状态已变化') from None except Exception: await db.rollback() raise