功能:增加测试批次生命周期管理

This commit is contained in:
焦龙言
2026-09-22 16:19:32 +08:00
parent c82f5e42dd
commit af5428d0e3
4 changed files with 1095 additions and 2 deletions
@@ -1,9 +1,52 @@
from sqlalchemy import select
from datetime import datetime
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import TestBatchStatus
from module_business.entity.do.master_do import (
BizCustomer,
BizCustomerSite,
BizPallet,
BizProductionLine,
BizStrain,
BizWarehouse,
)
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizInboundItem,
BizInboundOrder,
BizPalletEvent,
BizPalletInventory,
BizReturnItem,
BizReturnOrder,
BizShipmentItem,
BizShipmentOrder,
BizUsageRecord,
)
from module_business.entity.do.test_batch_do import BizTestBatch, BizTestBatchUser
_BATCH_OWNED_MODELS: tuple[type[Any], ...] = (
BizStrain,
BizCustomer,
BizCustomerSite,
BizProductionLine,
BizWarehouse,
BizPallet,
BizInboundOrder,
BizInboundItem,
BizFillingRecord,
BizShipmentOrder,
BizShipmentItem,
BizUsageRecord,
BizReturnOrder,
BizReturnItem,
BizPalletInventory,
BizPalletEvent,
)
class TestBatchDao:
"""测试批次访问数据库操作层。"""
@@ -45,3 +88,69 @@ class TestBatchDao:
statement = statement.join(BizTestBatchUser).where(BizTestBatchUser.user_id == user_id)
result = await db.execute(statement.order_by(BizTestBatch.batch_id.desc()))
return list(result.scalars().all())
@classmethod
async def list_batches(cls, db: AsyncSession) -> list[BizTestBatch]:
result = await db.execute(select(BizTestBatch).order_by(BizTestBatch.batch_id.desc()))
return list(result.scalars().all())
@classmethod
async def add_batch(cls, db: AsyncSession, batch: BizTestBatch) -> None:
db.add(batch)
@classmethod
async def list_batch_user_ids(cls, db: AsyncSession, batch_id: int) -> list[int]:
result = await db.execute(
select(BizTestBatchUser.user_id)
.where(BizTestBatchUser.batch_id == batch_id)
.order_by(BizTestBatchUser.user_id)
)
return list(result.scalars().all())
@classmethod
async def list_active_user_ids(cls, db: AsyncSession, user_ids: list[int]) -> list[int]:
if not user_ids:
return []
result = await db.execute(
select(SysUser.user_id)
.where(
SysUser.user_id.in_(user_ids),
SysUser.status == '0',
SysUser.del_flag == '0',
)
.order_by(SysUser.user_id)
)
return list(result.scalars().all())
@classmethod
async def replace_batch_users(
cls,
db: AsyncSession,
batch_id: int,
user_ids: list[int],
create_by: str,
create_time: datetime,
) -> None:
await db.execute(delete(BizTestBatchUser).where(BizTestBatchUser.batch_id == batch_id))
if user_ids:
db.add_all(
[
BizTestBatchUser(
batch_id=batch_id,
user_id=user_id,
create_by=create_by,
create_time=create_time,
)
for user_id in user_ids
]
)
@classmethod
async def count_owned_rows(cls, db: AsyncSession, batch_id: int) -> dict[str, int]:
counts: dict[str, int] = {}
for model in _BATCH_OWNED_MODELS:
result = await db.execute(
select(func.count()).select_from(model).where(model.test_batch_id == batch_id)
)
counts[model.__tablename__] = result.scalar() or 0
return counts
@@ -7,6 +7,7 @@ from pydantic.alias_generators import to_camel
from module_business.domain.enums import TestBatchStatus
PositiveId = Annotated[int, Field(gt=0)]
NonNegativeCount = Annotated[int, Field(ge=0)]
class TestBatchApiModel(BaseModel):
@@ -45,7 +46,7 @@ class TestBatchModel(TestBatchApiModel):
closed_at: datetime | None = None
purged_by: int | None = None
purged_at: datetime | None = None
purge_summary: dict[str, int] | None = None
purge_summary: dict[str, NonNegativeCount] | None = None
class TestBatchOptionModel(TestBatchApiModel):
@@ -0,0 +1,322 @@
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