"""把存量 Factory MES definition 一次性转换为通用 OpenAPI definition。 脚本默认只预检。数据库地址只从显式的 ``ZCBOT_MIGRATION_DB_URL`` 读取, 不会加载项目 ``.env``,也不会回退到 ``ZCBOT_DB_URL``。 """ from __future__ import annotations import argparse import os import sys from pathlib import Path from typing import Any from sqlalchemy import create_engine, select from sqlalchemy.orm import Session sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from core.external_systems.service import _normalized_config # noqa: E402 from core.storage.models import ( # noqa: E402 ExternalSystem, ExternalSystemDefinition, ) FACTORY_QUERY_GUIDANCE = ( "产量、良率、缺陷、库存、绩效、趋势和按日/月汇总等统计聚合查询," "统一先调用 BI dataset list,再执行匹配的数据集。日志和业务明细列表用于" "用户明确要求查看逐条记录、编号或追溯过程的场景。未匹配到 dataset 时," "先限定范围或向用户确认明细查询需求。" ) def migrated_config(raw: dict[str, Any] | None) -> dict[str, Any]: """物化旧 preset,并收敛为 query 模式的通用 OpenAPI 配置。""" source = dict(raw or {}) policies = { str(key).strip(): str(value).strip().lower() for key, value in (source.get("operation_policies") or {}).items() if str(key).strip() } for operation_id in source.get("allowed_post_operations") or []: if str(operation_id).strip(): policies.setdefault(str(operation_id).strip(), "read") policies.setdefault("bi_dataset_exec", "read") source.update( { "auth_type": source.get("auth_type") or "password_jwt", "login_path": source.get("login_path") or "/api/auth/token/", "username_field": source.get("username_field") or "username", "password_field": source.get("password_field") or "password", "token_field": source.get("token_field") or "access", "auth_header_name": source.get("auth_header_name") or "Authorization", "auth_header_template": source.get("auth_header_template") or "Bearer {token}", "operation_mode": "query", "operation_policies": policies, "query_guidance": source.get("query_guidance") or FACTORY_QUERY_GUIDANCE, "recommended_operation_ids": source.get("recommended_operation_ids") or ["bi_dataset_list", "bi_dataset_exec"], } ) source.pop("allowed_post_operations", None) return _normalized_config("generic_openapi", source) def _conflicting_definition( session: Session, definition: ExternalSystemDefinition ) -> ExternalSystemDefinition | None: owner_match = ( ExternalSystemDefinition.owner_type == "platform" if definition.owner_type == "platform" else ExternalSystemDefinition.owner_user_id == definition.owner_user_id ) return session.execute( select(ExternalSystemDefinition).where( ExternalSystemDefinition.provider == "generic_openapi", ExternalSystemDefinition.name == definition.name, owner_match, ) ).scalar_one_or_none() def migrate(session: Session, *, apply: bool) -> tuple[int, int]: statement = ( select(ExternalSystemDefinition) .where(ExternalSystemDefinition.provider == "factory_mes") .order_by(ExternalSystemDefinition.name) ) if apply: statement = statement.with_for_update() definitions = ( session.execute(statement) .scalars() .all() ) connection_count = 0 prepared: list[tuple[ExternalSystemDefinition, dict[str, Any], int]] = [] for definition in definitions: conflict = _conflicting_definition(session, definition) if conflict is not None: raise RuntimeError( f"definition name conflict: {definition.name} " f"({definition.definition_id} vs {conflict.definition_id})" ) config = migrated_config(definition.config) count = len( session.execute( select(ExternalSystem.external_system_id).where( ExternalSystem.definition_id == definition.definition_id ) ).all() ) connection_count += count prepared.append((definition, config, count)) print( f"[INFO] {definition.definition_id} name={definition.name!r} " f"connections={count}" ) print(f"[INFO] factory_mes definitions: {len(prepared)}") print(f"[INFO] affected connections: {connection_count}") if not apply: return len(prepared), connection_count for definition, config, _ in prepared: definition.provider = "generic_openapi" definition.config = config definition.revision += 1 active_connections = ( session.execute( select(ExternalSystem).where( ExternalSystem.definition_id == definition.definition_id, ExternalSystem.status == "active", ) ) .scalars() .all() ) for connection in active_connections: connection.verified_revision = definition.revision session.flush() remaining = session.execute( select(ExternalSystemDefinition.definition_id).where( ExternalSystemDefinition.provider == "factory_mes" ) ).first() if remaining is not None: raise RuntimeError("factory_mes definitions remain after migration") print("[OK] remaining factory_mes definitions: 0") return len(prepared), connection_count def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--apply", action="store_true", help="执行写入;省略时只做预检并回滚事务", ) args = parser.parse_args() database_url = os.environ.get("ZCBOT_MIGRATION_DB_URL", "").strip() if not database_url: print("[ERR] ZCBOT_MIGRATION_DB_URL is required", file=sys.stderr) return 2 engine = create_engine(database_url, pool_pre_ping=True, future=True) try: with Session(engine, future=True) as session: try: definitions, connections = migrate(session, apply=args.apply) if args.apply: session.commit() else: session.rollback() except Exception: session.rollback() raise finally: engine.dispose() action = "migrated" if args.apply else "validated" print(f"[OK] {action} definitions: {definitions}") print(f"[OK] affected connections: {connections}") return 0 if __name__ == "__main__": raise SystemExit(main())