zcbot/scripts/migrate_factory_mes_definit...

190 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""把存量 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())