"""批量操作命令:init / refresh / sync-all。""" from __future__ import annotations import argparse import logging import os from sapcli.client import ADTClient from sapcli.commands.crud import _sync_single from sapcli.exceptions import ConfigError, CyclicDependencyError from sapcli.manifest import Manifest, ManifestEntry, init_manifest from sapcli.scanner import scan_project from sapcli.sorter import topological_sort from sapcli.types import parse_object_name logger = logging.getLogger("sapcli.commands.batch") def cmd_init(args: argparse.Namespace, client: ADTClient) -> None: """项目初始化:扫描本地文件 → 查询 SAP → 生成 manifest.json。""" from datetime import datetime, timezone from sapcli.types import parse_object_name project_path = os.path.abspath(args.path) if not os.path.isdir(project_path): raise ConfigError(f"项目目录不存在: {project_path}") print("=" * 60) print(" sap-cli 项目初始化") print("=" * 60) print(f" 项目路径: {project_path}") # ── Step 1: 扫描本地文件 ── print(f"\n → 正在扫描本地文件...") scanned = scan_project(project_path) if not scanned: print(" ℹ 未扫描到 .abap 文件") print(" 请确认项目目录结构是否符合约定:") print(" reports/, classes/, interfaces/, functions/, domains/, ...") return print(f" ✓ 扫描到 {len(scanned)} 个本地文件\n") # ── Step 2: 逐个查询 SAP 状态 ── print(f" → 正在查询 SAP 系统状态...") manifest = init_manifest(project_path) counts = {"active": 0, "inactive": 0, "not_exists": 0} for obj in scanned: parsed = parse_object_name(obj.name, obj.type) try: status_info = client.get_object_status(parsed.obj_uri) except Exception as e: logger.warning("查询 %s 状态失败: %s", obj.name, e) status_info = {"exists": False, "status": "not_exists", "corr_nr": None} system_status = status_info["status"] corr_nr = status_info["corr_nr"] counts[system_status] = counts.get(system_status, 0) + 1 # 状态图标 if system_status == "active": icon = "✓" corr_display = f"corr: {corr_nr or '本地对象'}" elif system_status == "inactive": icon = "⚠" corr_display = f"corr: {corr_nr or '本地对象'}" else: icon = "✗" corr_display = "不存在" print(f" {icon} {obj.name:<20s} ({obj.type:<10s}) {system_status:<12s} {corr_display}") entry = ManifestEntry( name=obj.name, type=obj.type, file=obj.file, system_status=system_status, corr_nr=corr_nr, depends_on=[], last_sync=None, last_sync_result="pending", ) manifest.upsert(entry) # ── Step 3: 保存清单 ── manifest.save() total = sum(counts.values()) print(f"\n ✓ 清单已生成: {os.path.join(project_path, 'manifest.json')}") print(f" {total} 个对象: " f"{counts.get('active', 0)} 已激活, " f"{counts.get('inactive', 0)} 未激活, " f"{counts.get('not_exists', 0)} 不存在") def cmd_refresh(args: argparse.Namespace, client: ADTClient) -> None: """刷新清单:重新查询 SAP 状态,更新 system_status 和 corr_nr。""" from datetime import datetime, timezone from sapcli.types import parse_object_name project_path = os.path.abspath(args.path) manifest = Manifest.load(project_path) print("=" * 60) print(" sap-cli 清单刷新") print("=" * 60) print(f" 项目路径: {project_path}") print(f" 清单对象: {len(manifest.objects)}\n") print(f" → 正在查询 SAP 系统状态...") changed = 0 unchanged = 0 for name, entry in manifest.objects.items(): parsed = parse_object_name(name, entry.type) old_status = entry.system_status old_corr = entry.corr_nr try: status_info = client.get_object_status(parsed.obj_uri) except Exception as e: logger.warning("查询 %s 状态失败: %s", name, e) print(f" ✗ {name:<20s} ({entry.type:<10s}) 查询失败: {e}") unchanged += 1 continue new_status = status_info["status"] new_corr = status_info["corr_nr"] # 更新 entry.system_status = new_status entry.corr_nr = new_corr # 对比变化 status_changed = old_status != new_status corr_changed = old_corr != new_corr is_changed = status_changed or corr_changed if is_changed: changed += 1 changes = [] if status_changed: changes.append(f"状态变更: {old_status} → {new_status}") if corr_changed: changes.append(f"传输请求: {old_corr or '无'} → {new_corr or '无'}") change_str = ", ".join(changes) print(f" ~ {name:<20s} ({entry.type:<10s}) {new_status:<12s} corr: {new_corr or 'null':<15s} ({change_str})") else: unchanged += 1 print(f" ✓ {name:<20s} ({entry.type:<10s}) {new_status:<12s} corr: {new_corr or 'null':<15s} (未变化)") now = datetime.now(timezone.utc).isoformat(timespec="seconds") manifest.last_refresh = now manifest.save() print(f"\n ✓ 刷新完成") print(f" 变更: {changed} 个 | 未变化: {unchanged} 个") def cmd_sync_all(args: argparse.Namespace, client: ADTClient) -> None: """批量同步:读取清单 → 拓扑排序 → 逐个同步 → 更新清单。""" from datetime import datetime, timezone from sapcli.types import parse_object_name project_path = os.path.abspath(args.path) manifest = Manifest.load(project_path) dry_run = getattr(args, "dry_run", False) fail_fast = getattr(args, "fail_fast", False) all_entries = list(manifest.objects.values()) total = len(all_entries) print("=" * 60) if dry_run: print(" sap-cli 批量同步 (dry-run)") else: print(" sap-cli 批量同步") print("=" * 60) print(f" 项目路径: {project_path}") print(f" 清单对象: {total}") # ── Step 1: 扫描本地文件 vs 清单差异 ── scanned = scan_project(project_path) scanned_names = {s.name for s in scanned} manifest_names = set(manifest.objects.keys()) new_files = scanned_names - manifest_names missing_files = manifest_names - scanned_names if new_files: print(f"\n ⚠ {len(new_files)} 个本地文件未纳入清单,建议先 create:") for name in sorted(new_files): print(f" - {name}") # ── Step 2: 过滤待处理对象 ── pending = [] skipped_up_to_date = [] for entry in all_entries: # 检查本地文件是否存在 abs_file = manifest.file_path(entry) if entry.name in missing_files or not os.path.isfile(abs_file): print(f"\n ⚠ {entry.name} 本地文件缺失,将跳过") skipped_up_to_date.append(entry) continue # 跳过已同步且激活的对象 if entry.system_status == "active" and entry.last_sync_result == "success": skipped_up_to_date.append(entry) continue pending.append(entry) # ── Step 3: 拓扑排序 ── try: sorted_entries = topological_sort(all_entries) except CyclicDependencyError as e: print(f"\n ✗ {e}") raise # ── Step 4: dry-run 模式 ── if dry_run: print(f"\n 执行计划(按依赖排序):") pending_set = {e.name for e in pending} idx = 0 for entry in sorted_entries: idx += 1 if entry in skipped_up_to_date and entry.name not in pending_set: print(f" {idx}. {entry.name:<20s} ({entry.type:<10s}) → 跳过 [已是最新]") elif entry.name in missing_files: print(f" {idx}. {entry.name:<20s} ({entry.type:<10s}) → 跳过 [文件缺失]") else: status_info = f"{entry.system_status} → active" deps = entry.depends_on dep_str = f" [依赖: {', '.join(deps)}]" if deps else "" print(f" {idx}. {entry.name:<20s} ({entry.type:<10s}) → sync [{status_info}]{dep_str}") print(f"\n 待处理: {len(pending)} 个 | 跳过: {total - len(pending)} 个") return # ── Step 5: 执行同步 ── print(f" 待处理: {len(pending)} 个对象\n") succeeded: list[str] = [] failed: list[tuple[str, str]] = [] # (name, reason) skipped_deps: list[str] = [] # 因依赖失败而跳过的 failed_set: set[str] = set() # 失败的对象名集合(用于级联跳过) # 建立排序后的顺序映射 sorted_order = {entry.name: i for i, entry in enumerate(sorted_entries)} pending_sorted = sorted(pending, key=lambda e: sorted_order.get(e.name, 999)) for i, entry in enumerate(pending_sorted, 1): # 检查依赖是否全部成功 dep_failed = [d for d in entry.depends_on if d in failed_set] if dep_failed: skipped_deps.append(entry.name) failed_set.add(entry.name) entry.last_sync_result = "skipped" print(f" ⊘ [{i}/{len(pending_sorted)}] {entry.name} — 跳过(依赖失败: {', '.join(dep_failed)})") continue abs_file = manifest.file_path(entry) print(f"\n ── [{i}/{len(pending_sorted)}] {entry.name} ({entry.type}) ──") # 如果对象不存在,先创建 if entry.system_status == "not_exists": parsed = parse_object_name(entry.name, entry.type) print(f" ℹ 对象不存在,自动创建空对象...") try: if entry.type == "function": group_name = entry.name.split("/", 1)[0] if not client.function_group_exists(group_name): print(f" → 函数组 {group_name} 不存在,自动创建...") client.create_function_group(group_name) print(f" ✓ 函数组 {group_name} 创建成功") client.create_object(entry.type, entry.name, entry.name, source=None) print(f" ✓ 空对象创建成功") except Exception as e: error_msg = f"创建失败: {e}" print(f" ✗ {error_msg}") failed.append((entry.name, error_msg)) failed_set.add(entry.name) entry.last_sync_result = "failed" if fail_fast: break continue # 执行同步 success, error_msg, actual_corr_nr = _sync_single( entry.name, entry.type, abs_file, client, quiet=False, ) now = datetime.now(timezone.utc).isoformat(timespec="seconds") if success: succeeded.append(entry.name) entry.system_status = "active" entry.corr_nr = actual_corr_nr entry.last_sync = now entry.last_sync_result = "success" else: failed.append((entry.name, error_msg or "未知错误")) failed_set.add(entry.name) entry.last_sync = now entry.last_sync_result = "failed" print(f" ⊘ 跳过(标记为 failed)") if fail_fast: break # ── Step 6: 保存并输出汇总 ── manifest.save() print(f"\n{'=' * 60}") print(f" 批量同步完成") print(f"{'=' * 60}") if succeeded: print(f" ✓ 成功: {', '.join(succeeded)}") if failed: for name, reason in failed: print(f" ✗ 失败: {name} ({reason})") if skipped_deps: print(f" ⊘ 跳过: {', '.join(skipped_deps)}") if not failed and not skipped_deps: print(f" ⊘ 跳过: (无)") print(f" 清单已更新: {os.path.join(project_path, 'manifest.json')}")