Files
ShiErFeng/shierfeng-fastapi-backend/module_business/domain/test_batch_graph.py
T

360 lines
13 KiB
Python

import hashlib
import heapq
import json
import secrets
from collections import defaultdict, deque
from dataclasses import dataclass
from sqlalchemy import Column, MetaData, 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,
BizTunnel,
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_tunnel': (BizTunnel, BizTunnel.tunnel_id, BizTunnel.tunnel_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_tunnel': '隧道',
'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': '托盘事件',
}
_DELETE_TIE_BREAK_ORDER = (
'biz_pallet_inventory',
'biz_usage_record',
'biz_return_item',
'biz_shipment_item',
'biz_inbound_item',
'biz_return_order',
'biz_shipment_order',
'biz_inbound_order',
'biz_pallet_event',
'biz_filling_record',
'biz_production_line',
'biz_tunnel',
'biz_customer_site',
'biz_customer',
'biz_warehouse',
'biz_strain',
'biz_pallet',
)
ROOT_TABLES = {
'strain': 'biz_strain',
'customer': 'biz_customer',
'site': 'biz_customer_site',
'line': 'biz_production_line',
'tunnel': 'biz_tunnel',
'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',
'TUNNEL': 'biz_tunnel',
'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()
def derive_batch_delete_order(metadata: MetaData) -> list[str]:
"""从实际外键图生成子表优先的确定性删除顺序。"""
safe_tables = set(BATCH_MODELS)
metadata_batch_tables = {table.name for table in metadata.tables.values() if 'test_batch_id' in table.columns}
if metadata_batch_tables != safe_tables or set(_DELETE_TIE_BREAK_ORDER) != safe_tables:
raise RuntimeError('测试批次清理安全表集合不一致')
children_by_parent: dict[str, set[str]] = {table_name: set() for table_name in safe_tables}
parents_by_child: dict[str, set[str]] = {table_name: set() for table_name in safe_tables}
for child_name in sorted(safe_tables):
for foreign_key in metadata.tables[child_name].foreign_keys:
parent_name = foreign_key.target_fullname.rsplit('.', maxsplit=1)[0]
if parent_name not in safe_tables:
continue
children_by_parent[parent_name].add(child_name)
parents_by_child[child_name].add(parent_name)
priority = {table_name: index for index, table_name in enumerate(_DELETE_TIE_BREAK_ORDER)}
ready = [(priority[table_name], table_name) for table_name, children in children_by_parent.items() if not children]
heapq.heapify(ready)
order: list[str] = []
while ready:
_, child_name = heapq.heappop(ready)
order.append(child_name)
for parent_name in sorted(parents_by_child[child_name]):
children_by_parent[parent_name].remove(child_name)
if not children_by_parent[parent_name]:
heapq.heappush(ready, (priority[parent_name], parent_name))
if len(order) != len(safe_tables):
raise RuntimeError('测试批次清理外键图存在环')
if set(order) != safe_tables:
raise RuntimeError('测试批次清理安全表集合不一致')
return order
@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],
*,
for_update: bool,
) -> set[int]:
if not ids:
return set()
primary_key = BATCH_MODELS[table_name][1]
statement = select(primary_key).where(primary_key.in_(sorted(ids))).order_by(primary_key)
if for_update:
statement = statement.with_for_update()
result = await db.execute(statement)
return {row[0] for row in result.all()}
@classmethod
async def _compute_closure(
cls,
db: AsyncSession,
roots: list[AdoptionRootModel],
*,
for_update: bool,
) -> 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],
for_update=for_update,
)
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)
statement = statement.order_by(edge.result_column)
if for_update:
statement = statement.with_for_update()
result = await db.execute(statement)
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}
@classmethod
async def compute_closure(
cls,
db: AsyncSession,
roots: list[AdoptionRootModel],
) -> BatchSelection:
return await cls._compute_closure(db, roots, for_update=False)
@classmethod
async def compute_locked_closure(
cls,
db: AsyncSession,
roots: list[AdoptionRootModel],
) -> BatchSelection:
"""使用锁定读计算执行阶段闭包,避免复用一致性快照。"""
return await cls._compute_closure(db, roots, for_update=True)