功能:增加旧测试数据闭包纳入

This commit is contained in:
焦龙言
2026-09-23 13:15:59 +08:00
parent c97f6ed3bd
commit 91cf862248
5 changed files with 1009 additions and 3 deletions
@@ -0,0 +1,100 @@
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions.exception import ServiceWarning
from module_business.domain.test_batch_graph import BATCH_MODELS, ROOT_TABLES, BatchSelection
from module_business.entity.vo.test_batch_vo import AdoptionRootModel
class TestBatchAdoptionDao:
"""旧数据闭包锁定、展示与批量纳入。"""
@classmethod
async def lock_roots(
cls,
db: AsyncSession,
roots: list[AdoptionRootModel],
) -> None:
requested: dict[str, set[int]] = {}
for root in roots:
requested.setdefault(ROOT_TABLES[root.resource_type], set()).update(root.resource_ids)
for table_name in sorted(requested):
primary_key = BATCH_MODELS[table_name][1]
ids = sorted(requested[table_name])
result = await db.execute(
select(primary_key).where(primary_key.in_(ids)).order_by(primary_key).with_for_update()
)
if {row[0] for row in result.all()} != set(ids):
raise ServiceWarning('', '指定的旧数据不存在')
@classmethod
async def lock_selection(cls, db: AsyncSession, selection: BatchSelection) -> None:
for table_name in sorted(selection):
ids = sorted(selection[table_name])
if not ids:
continue
primary_key = BATCH_MODELS[table_name][1]
result = await db.execute(
select(primary_key).where(primary_key.in_(ids)).order_by(primary_key).with_for_update()
)
if len(result.all()) != len(ids):
raise ServiceWarning('', '预览范围已变化')
@classmethod
async def find_tagged_record(
cls,
db: AsyncSession,
selection: BatchSelection,
) -> tuple[str, int] | None:
for table_name in sorted(selection):
ids = sorted(selection[table_name])
if not ids:
continue
model, primary_key, _ = BATCH_MODELS[table_name]
result = await db.execute(
select(primary_key)
.where(
primary_key.in_(ids),
model.test_batch_id.is_not(None),
)
.order_by(primary_key)
.limit(1)
)
record_id = result.scalar_one_or_none()
if record_id is not None:
return table_name, record_id
return None
@classmethod
async def list_display_keys(
cls,
db: AsyncSession,
selection: BatchSelection,
) -> dict[str, list[str]]:
keys: dict[str, list[str]] = {}
for table_name in BATCH_MODELS:
ids = sorted(selection[table_name])
if not ids:
keys[table_name] = []
continue
_, primary_key, display_column = BATCH_MODELS[table_name]
result = await db.execute(select(display_column).where(primary_key.in_(ids)).order_by(display_column))
keys[table_name] = sorted(str(row[0]) for row in result.all())
return keys
@classmethod
async def set_test_batch_id(
cls,
db: AsyncSession,
table_name: str,
ids: list[int],
batch_id: int,
) -> None:
if not ids:
return
model, primary_key, _ = BATCH_MODELS[table_name]
result = await db.execute(
update(model).where(primary_key.in_(ids), model.test_batch_id.is_(None)).values(test_batch_id=batch_id)
)
if result.rowcount != len(ids):
raise ServiceWarning('', '存在记录已属于其他测试批次')
@@ -149,8 +149,39 @@ class TestBatchDao:
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)
)
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
@classmethod
async def store_preview(
cls,
db: AsyncSession,
batch: BizTestBatch,
*,
action: str,
token_hash: str,
scope_hash: str,
expires_at: datetime,
update_by: str,
update_time: datetime,
) -> None:
"""在已锁定的批次对象上保存一次性预览状态。"""
batch.preview_action = action
batch.preview_token_hash = token_hash
batch.preview_scope_hash = scope_hash
batch.preview_expires_at = expires_at
batch.update_by = update_by
batch.update_time = update_time
@classmethod
async def clear_preview(
cls,
db: AsyncSession,
batch: BizTestBatch,
) -> None:
"""消费已锁定批次上的预览令牌与范围摘要。"""
batch.preview_action = None
batch.preview_token_hash = None
batch.preview_scope_hash = None
batch.preview_expires_at = None
@@ -0,0 +1,261 @@
import hashlib
import json
import secrets
from collections import defaultdict, deque
from dataclasses import dataclass
from sqlalchemy import Column, select
from sqlalchemy.ext.asyncio import AsyncSession
from config.database import Base
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.vo.test_batch_vo import AdoptionRootModel
BatchSelection = dict[str, frozenset[int]]
BATCH_MODELS = {
'biz_strain': (BizStrain, BizStrain.strain_id, BizStrain.strain_code),
'biz_customer': (BizCustomer, BizCustomer.customer_id, BizCustomer.customer_code),
'biz_customer_site': (BizCustomerSite, BizCustomerSite.site_id, BizCustomerSite.site_code),
'biz_production_line': (
BizProductionLine,
BizProductionLine.line_id,
BizProductionLine.line_code,
),
'biz_warehouse': (BizWarehouse, BizWarehouse.warehouse_id, BizWarehouse.warehouse_code),
'biz_pallet': (BizPallet, BizPallet.pallet_id, BizPallet.pallet_code),
'biz_inbound_order': (BizInboundOrder, BizInboundOrder.inbound_id, BizInboundOrder.inbound_no),
'biz_inbound_item': (
BizInboundItem,
BizInboundItem.inbound_item_id,
BizInboundItem.inbound_item_id,
),
'biz_filling_record': (
BizFillingRecord,
BizFillingRecord.filling_id,
BizFillingRecord.filling_no,
),
'biz_shipment_order': (
BizShipmentOrder,
BizShipmentOrder.shipment_id,
BizShipmentOrder.shipment_no,
),
'biz_shipment_item': (
BizShipmentItem,
BizShipmentItem.shipment_item_id,
BizShipmentItem.shipment_item_id,
),
'biz_usage_record': (BizUsageRecord, BizUsageRecord.usage_id, BizUsageRecord.usage_no),
'biz_return_order': (BizReturnOrder, BizReturnOrder.return_id, BizReturnOrder.return_no),
'biz_return_item': (
BizReturnItem,
BizReturnItem.return_item_id,
BizReturnItem.return_item_id,
),
'biz_pallet_inventory': (
BizPalletInventory,
BizPalletInventory.pallet_id,
BizPalletInventory.pallet_id,
),
'biz_pallet_event': (BizPalletEvent, BizPalletEvent.event_id, BizPalletEvent.event_id),
}
BATCH_LABELS = {
'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': '托盘事件',
}
ROOT_TABLES = {
'strain': 'biz_strain',
'customer': 'biz_customer',
'site': 'biz_customer_site',
'line': 'biz_production_line',
'warehouse': 'biz_warehouse',
'pallet': 'biz_pallet',
'inbound': 'biz_inbound_order',
'filling': 'biz_filling_record',
'shipment': 'biz_shipment_order',
'usage': 'biz_usage_record',
'return': 'biz_return_order',
}
_EVENT_SOURCE_TABLES = {
'INBOUND': 'biz_inbound_order',
'FILLING': 'biz_filling_record',
'SHIPMENT': 'biz_shipment_order',
'USAGE': 'biz_usage_record',
'RETURN': 'biz_return_order',
}
def canonical_selection(selection: BatchSelection) -> bytes:
payload = {table: sorted(ids) for table, ids in sorted(selection.items()) if ids}
return json.dumps(payload, ensure_ascii=True, separators=(',', ':'), sort_keys=True).encode()
def selection_hash(selection: BatchSelection) -> str:
return hashlib.sha256(canonical_selection(selection)).hexdigest()
def issue_preview_token() -> tuple[str, str]:
raw = secrets.token_urlsafe(32)
return raw, hashlib.sha256(raw.encode()).hexdigest()
@dataclass(frozen=True)
class _GraphEdge:
source_table: str
target_table: str
filter_column: Column
result_column: Column
source_type: str | None = None
class TestBatchGraph:
"""基于批次模型外键计算可原子纳入的双向关联闭包。"""
@staticmethod
def _foreign_key_edges() -> dict[str, list[_GraphEdge]]:
edges: dict[str, list[_GraphEdge]] = defaultdict(list)
batch_tables = set(BATCH_MODELS)
for child_table_name in sorted(batch_tables):
child_table = Base.metadata.tables[child_table_name]
child_pk = BATCH_MODELS[child_table_name][1]
for foreign_key in sorted(
child_table.foreign_keys,
key=lambda item: (item.parent.name, item.target_fullname),
):
parent_table_name = foreign_key.column.table.name
if foreign_key.parent.name == 'test_batch_id' or parent_table_name not in batch_tables:
continue
edges[child_table_name].append(
_GraphEdge(
source_table=child_table_name,
target_table=parent_table_name,
filter_column=child_pk,
result_column=foreign_key.parent,
)
)
edges[parent_table_name].append(
_GraphEdge(
source_table=parent_table_name,
target_table=child_table_name,
filter_column=foreign_key.parent,
result_column=child_pk,
)
)
return edges
@classmethod
def _all_edges(cls) -> dict[str, list[_GraphEdge]]:
edges = cls._foreign_key_edges()
event_pk = BATCH_MODELS['biz_pallet_event'][1]
for source_type, root_table in sorted(_EVENT_SOURCE_TABLES.items()):
edges[root_table].append(
_GraphEdge(
source_table=root_table,
target_table='biz_pallet_event',
filter_column=BizPalletEvent.source_id,
result_column=event_pk,
source_type=source_type,
)
)
edges['biz_pallet_event'].append(
_GraphEdge(
source_table='biz_pallet_event',
target_table=root_table,
filter_column=event_pk,
result_column=BizPalletEvent.source_id,
source_type=source_type,
)
)
for table_edges in edges.values():
table_edges.sort(
key=lambda edge: (
edge.target_table,
edge.filter_column.name,
edge.result_column.name,
edge.source_type or '',
)
)
return edges
@staticmethod
async def _existing_ids(db: AsyncSession, table_name: str, ids: set[int]) -> set[int]:
if not ids:
return set()
primary_key = BATCH_MODELS[table_name][1]
result = await db.execute(select(primary_key).where(primary_key.in_(sorted(ids))).order_by(primary_key))
return {row[0] for row in result.all()}
@classmethod
async def compute_closure(
cls,
db: AsyncSession,
roots: list[AdoptionRootModel],
) -> BatchSelection:
selected: dict[str, set[int]] = {table_name: set() for table_name in BATCH_MODELS}
requested: dict[str, set[int]] = defaultdict(set)
for root in roots:
requested[ROOT_TABLES[root.resource_type]].update(root.resource_ids)
queue: deque[str] = deque()
for table_name in sorted(requested):
existing = await cls._existing_ids(db, table_name, requested[table_name])
selected[table_name].update(existing)
if existing:
queue.append(table_name)
expanded: dict[str, set[int]] = {table_name: set() for table_name in BATCH_MODELS}
edges = cls._all_edges()
while queue:
source_table = queue.popleft()
pending_ids = selected[source_table] - expanded[source_table]
if not pending_ids:
continue
expanded[source_table].update(pending_ids)
for edge in edges.get(source_table, []):
statement = select(edge.result_column).where(edge.filter_column.in_(sorted(pending_ids)))
if edge.source_type is not None:
statement = statement.where(BizPalletEvent.source_type == edge.source_type)
result = await db.execute(statement.order_by(edge.result_column))
related_ids = {row[0] for row in result.all() if row[0] is not None}
new_ids = related_ids - selected[edge.target_table]
if new_ids:
selected[edge.target_table].update(new_ids)
queue.append(edge.target_table)
return {table_name: frozenset(selected[table_name]) for table_name in BATCH_MODELS}
@@ -0,0 +1,167 @@
import hashlib
import hmac
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel
from exceptions.exception import PermissionException, ServiceWarning
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.test_batch_adoption_dao import TestBatchAdoptionDao
from module_business.dao.test_batch_dao import TestBatchDao
from module_business.domain.enums import TestBatchStatus
from module_business.domain.test_batch_graph import (
BATCH_LABELS,
BATCH_MODELS,
TestBatchGraph,
issue_preview_token,
selection_hash,
)
from module_business.entity.vo.test_batch_vo import (
AdoptionExecuteRequestModel,
AdoptionPreviewRequestModel,
BatchImpactModel,
TestBatchPreviewModel,
)
from module_business.service.test_batch_access_service import TestBatchAccessService
class TestBatchAdoptionService:
"""预览并原子纳入已存在的正式数据闭包。"""
@staticmethod
def _assert_admin(current_user: CurrentUserModel) -> None:
TestBatchAccessService.assert_super_admin(current_user)
@staticmethod
def _user_name(current_user: CurrentUserModel) -> str:
user = current_user.user
if user is None or user.user_name is None:
raise PermissionException('', '当前用户信息不可用')
return user.user_name
@staticmethod
def _assert_active(batch: Any | None) -> None:
if batch is None:
raise ServiceWarning('', '测试批次不存在')
status = batch.status
if status == TestBatchStatus.CLOSED.value:
raise ServiceWarning('', '测试批次已关闭')
if status == TestBatchStatus.PURGED.value:
raise ServiceWarning('', '测试批次已清理')
if status != TestBatchStatus.ACTIVE.value:
raise ServiceWarning('', '测试批次状态异常')
@staticmethod
def _assert_token(batch: object, raw_token: str, now: datetime) -> None:
supplied_hash = hashlib.sha256(raw_token.encode()).hexdigest()
stored_hash = getattr(batch, 'preview_token_hash', None)
comparable_hash = stored_hash if isinstance(stored_hash, str) else '0' * 64
if not hmac.compare_digest(comparable_hash, supplied_hash):
raise ServiceWarning('', '预览令牌无效')
if getattr(batch, 'preview_action', None) != 'ADOPT':
raise ServiceWarning('', '预览令牌无效')
expires_at = getattr(batch, 'preview_expires_at', None)
if expires_at is None or expires_at <= now:
raise ServiceWarning('', '预览令牌已过期')
@classmethod
async def preview(
cls,
db: AsyncSession,
batch_id: int,
command: AdoptionPreviewRequestModel,
current_user: CurrentUserModel,
) -> TestBatchPreviewModel:
cls._assert_admin(current_user)
user_name = cls._user_name(current_user)
try:
batch = await TestBatchDao.get_batch_for_update(db, batch_id)
cls._assert_active(batch)
selection = await TestBatchGraph.compute_closure(db, command.roots)
if await TestBatchAdoptionDao.find_tagged_record(db, selection) is not None:
raise ServiceWarning('', '存在记录已属于其他测试批次')
display_keys = await TestBatchAdoptionDao.list_display_keys(db, selection)
now = datetime.now()
expires_at = now + timedelta(seconds=600)
raw_token, token_hash = issue_preview_token()
await TestBatchDao.store_preview(
db,
batch,
action='ADOPT',
token_hash=token_hash,
scope_hash=selection_hash(selection),
expires_at=expires_at,
update_by=user_name,
update_time=now,
)
await db.commit()
impacts = [
BatchImpactModel(
table_name=table_name,
label=BATCH_LABELS[table_name],
count=len(selection[table_name]),
keys=display_keys[table_name],
)
for table_name in BATCH_MODELS
]
return TestBatchPreviewModel(
batch_id=batch_id,
batch_name=batch.batch_name,
preview_token=raw_token,
expires_at=expires_at,
total_count=sum(item.count for item in impacts),
impacts=impacts,
)
except Exception:
await db.rollback()
raise
@classmethod
async def execute(
cls,
db: AsyncSession,
batch_id: int,
command: AdoptionExecuteRequestModel,
current_user: CurrentUserModel,
) -> CrudResponseModel:
cls._assert_admin(current_user)
user_name = cls._user_name(current_user)
try:
batch = await TestBatchDao.get_batch_for_update(db, batch_id)
cls._assert_active(batch)
if command.batch_name != batch.batch_name:
raise ServiceWarning('', '测试批次名称不匹配')
cls._assert_token(batch, command.preview_token, datetime.now())
await TestBatchAdoptionDao.lock_roots(db, command.roots)
first_selection = await TestBatchGraph.compute_closure(db, command.roots)
await TestBatchAdoptionDao.lock_selection(db, first_selection)
locked_selection = await TestBatchGraph.compute_closure(db, command.roots)
expected_scope_hash = batch.preview_scope_hash
if (
selection_hash(first_selection) != expected_scope_hash
or selection_hash(locked_selection) != expected_scope_hash
):
raise ServiceWarning('', '预览范围已变化')
if await TestBatchAdoptionDao.find_tagged_record(db, locked_selection) is not None:
raise ServiceWarning('', '存在记录已属于其他测试批次')
for table_name in sorted(locked_selection):
await TestBatchAdoptionDao.set_test_batch_id(
db,
table_name,
sorted(locked_selection[table_name]),
batch_id,
)
now = datetime.now()
await TestBatchDao.clear_preview(db, batch)
batch.update_by = user_name
batch.update_time = now
await db.commit()
return CrudResponseModel(is_success=True, message='旧测试数据纳入成功')
except Exception:
await db.rollback()
raise