"""CRUD 命令:download / sync / info / delete / create。""" from __future__ import annotations import argparse import logging import os import re import xml.etree.ElementTree as ET from sapcli.client import ADTClient from sapcli.exceptions import ( ConfigError, CreateError, DeleteError, InvalidNameError, LockError, ObjectAlreadyExistsError, ObjectNotFoundError, SapCliError, ) from sapcli.manifest import Manifest, ManifestEntry from sapcli.types import get_r3tr_object_code, get_type_config, parse_object_name DDIC_TYPES = {"domain", "dataelement", "table", "structure", "tabletype"} # NW 7.40 ADT 对 DDIC 对象(structure/table/tabletype/dataelement/domain)的 */* 响应 # 不含 description / packageRef,需回退查文本表补描述、查 TADIR 补开发包。 # 描述表的语言列固定为 DDLANGUAGE、文本列固定为 DDTEXT。 _DDIC_DESC_TABLE: dict[str, tuple[str, str]] = { "structure": ("DD02T", "TABNAME"), "table": ("DD02T", "TABNAME"), "tabletype": ("DD40T", "TYPENAME"), "dataelement": ("DD04T", "ROLLNAME"), "domain": ("DD01T", "DOMNAME"), } # DDIC 对象 → TADIR.OBJECT 代码(用于补开发包 DEVCLASS)。 _DDIC_PACKAGE_OBJECT: dict[str, str] = { "structure": "TABL", "table": "TABL", "tabletype": "TTYP", "dataelement": "DTEL", "domain": "DOMA", } # ADT masterLanguage 返回 ISO 码(如 'ZH'/'EN'),文本表 DDLANGUAGE 用 SAP 单字符键。 _ISO_TO_SAP_LANGUAGE: dict[str, str] = { "ZH": "1", "EN": "E", "DE": "D", "JA": "J", "KO": "3", } logger = logging.getLogger("sapcli.commands.crud") _PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) LOG_DIR = os.path.join(_PROJECT_ROOT, "log") LOG_FILE = os.path.join(LOG_DIR, "adt_tools.log") DEFAULT_TEMPLATES: dict[str, str] = { "report": 'REPORT {name}.\nWRITE: / \'Hello from {name}\'.\n', "class": ( 'CLASS {name} DEFINITION\n' ' PUBLIC\n' ' FINAL\n' ' CREATE PUBLIC.\n' ' PUBLIC SECTION.\n' ' METHODS: hello.\n' 'ENDCLASS.\n' 'CLASS {name} IMPLEMENTATION.\n' ' METHOD hello.\n' ' ENDMETHOD.\n' 'ENDCLASS.\n' ), "function": ( 'FUNCTION {name}\n' ' EXPORTING\n' ' VALUE(EV_RESULT) TYPE STRING.\n' ' ev_result = \'hello\'.\n' 'ENDFUNCTION.\n' ), "interface": ( 'INTERFACE {name}\n' ' PUBLIC.\n' ' METHODS: hello.\n' 'ENDINTERFACE.\n' ), # 以下类型不提供完整业务骨架,仅给出占位注释。 # 建议通过 --source 指定本地源码文件,或用 --definition 创建 DDIC 定义。 "include": ( '*&---------------------------------------------------------------------*\n' '*& Include {name}\n' '*&---------------------------------------------------------------------*\n' '* Include 程序骨架:在此编写可被其它程序 INCLUDE 的 ABAP 片段。\n' '* 提示:用 --source 指定本地源码文件可覆盖本模板。\n' ), "messageclass": ( '* Message Class {name}\n' '* 消息类(T100)不包含 ABAP 源码,消息条目通过 SE91 维护。\n' '* 提示:建议用 --definition 创建,或通过 SAP GUI (SE91) 维护消息。\n' ), "view": ( '* Database View {name}\n' '* 数据库视图 DDL 骨架(基础 SELECT 形式,按需替换):\n' '* SELECT <字段列表>\n' '* FROM <主表>\n' '* [JOIN <关联表> ON <条件>]\n' '* [WHERE <过滤条件>]\n' '* 提示:用 --source 指定完整视图定义可覆盖本模板。\n' ), "searchhelp": ( '* Search Help {name}\n' '* 搜索帮助不包含 ABAP 源码,定义含数据源与显示字段。\n' '* 提示:建议用 --definition 创建,或通过 SAP GUI (SE11) 维护。\n' ), "lockobject": ( '* Lock Object {name}\n' '* 锁对象不包含 ABAP 源码,定义含锁定模式与主表。\n' '* 提示:建议用 --definition 创建,或通过 SAP GUI (SE11) 维护。\n' ), } def print_source_preview(source: str, max_lines: int = 20) -> None: """打印源代码预览。""" lines = source.splitlines() print(f" ┌─── 源代码 (前 {max_lines} 行) ──────────────────────") for i, line in enumerate(lines[:max_lines], 1): print(f" │ {i:4d} | {line}") if len(lines) > max_lines: print(f" │ ... 省略剩余 {len(lines) - max_lines} 行 ...") print(f" └────────────────────────────────────────────") def cmd_download(args: argparse.Namespace, client: ADTClient) -> None: """下载 SAP 对象源代码到本地文件。""" obj_type: str = args.type name: str = args.name save_dir: str = args.path parsed = parse_object_name(name, obj_type) type_label = get_type_config(obj_type).label if parsed.src_uri is None: print(f"\n ✗ {type_label}没有源代码,不支持 download 操作") print(f" functiongroup 是函数模块的容器,不包含可编辑的源代码文件") raise InvalidNameError(f"{type_label}没有源代码,不支持 download 操作") print("=" * 60) print(" SAP ADT 源代码下载") print("=" * 60) print(f" 对象类型: {type_label}") print(f" 对象名称: {parsed.display_name}") print(f" 保存路径: {save_dir}") print(f"\n → 检查对象是否存在...") if not client.object_exists(parsed.exists_uri or parsed.obj_uri): print(f" ✗ 对象不存在: {parsed.display_name}") print(f" 请确认名称和类型是否正确") raise ObjectNotFoundError(parsed.display_name, obj_type) print(" ✓ 对象存在") print(f"\n → 正在下载源代码...") source = client.get_source(parsed.src_uri) source = source.replace("\r\n", "\n").replace("\r", "\n") line_count = len(source.splitlines()) print(f" ✓ 源代码下载成功! {len(source)} 字符, {line_count} 行") if not os.path.isdir(save_dir): os.makedirs(save_dir, exist_ok=True) filename = f"{parsed.file_base}.abap" filepath = os.path.join(save_dir, filename) with open(filepath, "w", encoding="utf-8") as f: f.write(source) print(f"\n ✓ 文件已保存: {filepath}") print() print_source_preview(source) print(f"\n ✓ 下载完成!") print(f" 文件: {filepath}") print(f" 大小: {len(source)} 字符, {line_count} 行") def cmd_sync(args: argparse.Namespace, client: ADTClient) -> None: """同步单个对象源代码到 SAP 并激活。""" obj_type: str = args.type name: str = args.name file_path: str = args.path # 向后兼容:检测项目根目录(从文件路径向上查找 manifest.json) if not hasattr(args, "project_path") or not args.project_path: parent = os.path.dirname(os.path.abspath(file_path)) if os.path.isfile(os.path.join(parent, "manifest.json")): args.project_path = parent else: args.project_path = None parsed = parse_object_name(name, obj_type) type_label = get_type_config(obj_type).label if parsed.src_uri is None: print(f"\n ✗ {type_label}没有源代码,不支持 sync 操作") print(f" functiongroup 是函数模块的容器,不包含可编辑的源代码文件") raise InvalidNameError(f"{type_label}没有源代码,不支持 sync 操作") if not os.path.isfile(file_path): print(f"\n ✗ 文件不存在: {file_path}") raise ConfigError(f"文件不存在: {file_path}") with open(file_path, "r", encoding="utf-8") as f: source = f.read() print("=" * 60) print(" SAP ADT 源代码同步激活") print("=" * 60) print(f" 对象类型: {type_label}") print(f" 对象名称: {parsed.display_name}") print(f" 本地文件: {file_path}") print(f" 源代码: {len(source)} 字符, {len(source.splitlines())} 行") print(f" 流程: 检查/创建 → 写入 → 语法检查 → 激活") corr_nr_arg = getattr(args, "corr_nr", None) success, error_msg, actual_corr_nr = _sync_single( name, obj_type, file_path, client, corr_nr=corr_nr_arg, ) if success: # 更新清单(如果适用) _update_manifest_after_sync(args, name, obj_type, file_path, actual_corr_nr) return # 失败时也更新清单 _update_manifest_after_sync(args, name, obj_type, file_path, actual_corr_nr, failed=True) raise SapCliError(error_msg or "同步失败") def _sync_single( name: str, obj_type: str, file_path: str, client: ADTClient, corr_nr: str | None = None, quiet: bool = False, ) -> tuple[bool, str | None, str | None]: """单个对象的同步核心逻辑。 Args: name: 对象名称(function 类型含 /) obj_type: 对象类型 file_path: 本地源代码文件路径 client: ADT 客户端 corr_nr: 传输请求号(可选) quiet: 是否静默模式(批量同步时减少输出) Returns: (success, error_msg, actual_corr_nr) """ parsed = parse_object_name(name, obj_type) type_label = get_type_config(obj_type).label if parsed.src_uri is None: return False, f"{type_label}没有源代码,不支持 sync 操作", None if not os.path.isfile(file_path): return False, f"文件不存在: {file_path}", None with open(file_path, "r", encoding="utf-8") as f: source = f.read() if not quiet: print(f"\n → 检查对象是否存在...") if not client.object_exists(parsed.exists_uri or parsed.obj_uri): if not quiet: print(f" ℹ 对象不存在,自动创建空对象...") try: client.create_object(obj_type, name, name, source=None) if not quiet: print(f" ✓ 空对象创建成功,进入同步流程") except Exception as e: return False, f"创建失败: {e}", None else: if not quiet: print(" ✓ 对象存在") # ── Step 1: 锁定 → 写入 → 解锁 ── if not quiet: print("\n ── 锁定 → 写入 → 解锁 ──") try: if corr_nr: lock_handle, _ = client.lock(parsed.obj_uri, corr_nr) if not quiet: print(f" ✓ 锁定成功(传输请求: {corr_nr})") else: try: lock_handle, detected_corr_nr = client.lock(parsed.obj_uri) if detected_corr_nr: corr_nr = detected_corr_nr if not quiet: print(f" ✓ 锁定成功(对象已绑定传输请求: {corr_nr})") else: if not quiet: print(f" ✓ 锁定成功(本地对象,无需传输请求)") except LockError: if not quiet: print(" ℹ 对象需要传输请求号") corr_nr = _select_transport_request(client) if corr_nr: lock_handle, _ = client.lock(parsed.obj_uri, corr_nr) if not quiet: print(f" ✓ 锁定成功(传输请求: {corr_nr})") else: return False, "锁定失败: 无法获取传输请求号", None except LockError as e: return False, str(e), None try: client.set_source(parsed.src_uri, source, lock_handle, corr_nr) if not quiet: print(f" ✓ 源代码写入成功") finally: client.unlock(parsed.obj_uri, lock_handle) if not quiet: print(f" ✓ 解锁成功") # ── Step 2: 语法检查 ── if not quiet: print("\n ── 语法检查 ──") try: check_ok, check_msgs = client.syntax_check(name, parsed.obj_uri) except Exception as e: if not quiet: print(f" ℹ 语法检查异常(跳过,直接激活): {e}") check_ok = True check_msgs = [] if not check_ok: errors = [m for m in check_msgs if m["type"] == "E"] warnings = [m for m in check_msgs if m["type"] == "W"] if not quiet: print(f" ✗ 语法检查未通过! {len(errors)} 个错误, {len(warnings)} 个警告") for e in errors: print(f" [错误] 行 {e['line']}: {e['text']}") for w in warnings: print(f" [警告] 行 {w['line']}: {w['text']}") error_summary = "; ".join(f"行{e['line']}: {e['text']}" for e in errors) return False, f"语法检查未通过: {error_summary}", corr_nr if not quiet: print(" ✓ 语法检查通过") # ── Step 3: 激活 ── if not quiet: print("\n ── 激活对象 ──") success, messages = client.activate(name, parsed.obj_uri, corr_nr) if success: if not quiet: print(f" ✓ 激活成功") print(f"\n {'=' * 60}") print(f" ✓ {parsed.display_name} 已成功同步并激活") print(f" 文件: {file_path}") print(f" 源代码: {len(source)} 字符, {len(source.splitlines())} 行") print(f" 日志: {LOG_FILE}") print(f" {'=' * 60}") return True, None, corr_nr errors = [m for m in messages if m["type"] == "E"] if not quiet: warnings = [m for m in messages if m["type"] == "W"] print(f" ✗ 激活失败! {len(errors)} 个错误, {len(warnings)} 个警告") for e in errors: print(f" [错误] 行 {e['line']}: {e['text']}") for w in warnings: print(f" [警告] 行 {w['line']}: {w['text']}") error_summary = "; ".join(f"行{e['line']}: {e['text']}" for e in errors) return False, f"激活失败: {error_summary}", corr_nr def _update_manifest_after_sync( args: argparse.Namespace, name: str, obj_type: str, file_path: str, corr_nr: str | None, failed: bool = False, ) -> None: """sync 成功/失败后尝试更新清单(如果适用)。""" from datetime import datetime, timezone project_path = getattr(args, "project_path", None) if not project_path: return manifest_path = os.path.join(project_path, "manifest.json") if not os.path.isfile(manifest_path): return try: manifest = Manifest.load(project_path) now = datetime.now(timezone.utc).isoformat(timespec="seconds") # 确定相对文件路径 try: rel_file = os.path.relpath(file_path, project_path).replace("\\", "/") except ValueError: rel_file = file_path entry = manifest.get(name) if entry: entry.corr_nr = corr_nr entry.system_status = "active" if not failed else entry.system_status entry.last_sync = now entry.last_sync_result = "failed" if failed else "success" else: manifest.upsert(ManifestEntry( name=name, type=obj_type, file=rel_file, system_status="active" if not failed else "inactive", corr_nr=corr_nr, depends_on=[], last_sync=now, last_sync_result="failed" if failed else "success", )) manifest.save() except Exception as e: logger.warning("更新清单失败: %s", e) def _query_transport_request( client: ADTClient, obj_type: str, display_name: str ) -> str: """查对象当前所在(未释放)的传输请求号。纯只读查 E071;失败优雅降级。""" r3tr_code = get_r3tr_object_code(obj_type) if not r3tr_code: return "—(非独立传输对象)" obj_name_upper = display_name.upper().split("/")[-1] if not re.fullmatch(r"[A-Za-z0-9_/$]+", obj_name_upper): return "(对象名非法,跳过)" try: sql = ( f"SELECT trkorr FROM e071 WHERE pgmid='R3TR' " f"AND object='{r3tr_code}' AND obj_name='{obj_name_upper}' " f"UP TO 10 ROWS" ) result = client.query_table_data(sql, max_rows=10) trkorrs = sorted({row[0] for row in result.get("rows", []) if row and row[0]}) return ", ".join(trkorrs) if trkorrs else "无" except Exception: logger.debug("查询 E071 传输请求失败", exc_info=True) return "(无法获取)" def _sap_language_key(master_language: str) -> str: """把 ADT masterLanguage(ISO 码,如 'ZH'/'EN')转成 SAP 单字符语言键。 已是单字符时原样返回;无法映射时原样返回(回退顺序会自然跳过不匹配项)。 """ lang = (master_language or "").strip().upper() if not lang: return "" if len(lang) == 1: return lang return _ISO_TO_SAP_LANGUAGE.get(lang, lang) def _pick_description_text(rows, preferred_lang: str = "") -> str: """按「登录语言 -> 'E' -> '1' -> 首行」回退,从 (语言, 文本) 行中挑描述文本。""" if not rows: return "" by_lang: dict[str, str] = {} order: list[str] = [] for lang, text in rows: lang = (lang or "").strip().upper() if lang not in by_lang: by_lang[lang] = (text or "").strip() order.append(lang) priority = [p for p in (preferred_lang, "E", "1") if p] for p in priority: p = p.strip().upper() for lang in order: if lang == p and by_lang[lang]: return by_lang[lang] for lang in order: if by_lang[lang]: return by_lang[lang] return "" def _query_ddic_description( client: ADTClient, obj_type: str, obj_name: str, preferred_lang: str ) -> str: """ADT 返回空描述时,查描述表补 DDIC 对象描述;失败返回 '' 不抛。""" if obj_type not in _DDIC_DESC_TABLE: return "" table, key_col = _DDIC_DESC_TABLE[obj_type] name_upper = obj_name.upper().split("/")[-1] if not re.fullmatch(r"[A-Za-z0-9_/$]+", name_upper): return "" try: sql = ( f"SELECT DDLANGUAGE, DDTEXT FROM {table} " f"WHERE {key_col} IN ('{name_upper}')" ) result = client.query_table_data(sql, max_rows=20) columns = result.get("columns", []) rows = result.get("rows", []) lang_idx = columns.index("DDLANGUAGE") if "DDLANGUAGE" in columns else 0 text_idx = columns.index("DDTEXT") if "DDTEXT" in columns else 1 pairs = [] for row in rows: if row is None: continue lang = row[lang_idx] if lang_idx < len(row) else "" text = row[text_idx] if text_idx < len(row) else "" pairs.append((lang, text)) return _pick_description_text(pairs, preferred_lang) except Exception: logger.debug("查询 %s 描述失败", table, exc_info=True) return "" def _query_ddic_package(client: ADTClient, obj_type: str, obj_name: str) -> str: """ADT 返回空开发包时,查 TADIR 补 DEVCLASS;失败返回 '' 不抛。""" if obj_type not in _DDIC_PACKAGE_OBJECT: return "" obj_code = _DDIC_PACKAGE_OBJECT[obj_type] name_upper = obj_name.upper().split("/")[-1] if not re.fullmatch(r"[A-Za-z0-9_/$]+", name_upper): return "" try: sql = ( f"SELECT DEVCLASS FROM TADIR " f"WHERE OBJECT IN ('{obj_code}') AND OBJ_NAME IN ('{name_upper}')" ) result = client.query_table_data(sql, max_rows=10) columns = result.get("columns", []) rows = result.get("rows", []) dev_idx = columns.index("DEVCLASS") if "DEVCLASS" in columns else 0 for row in rows: if row and dev_idx < len(row) and row[dev_idx]: return str(row[dev_idx]).strip() return "" except Exception: logger.debug("查询 TADIR 开发包失败", exc_info=True) return "" def cmd_info(args: argparse.Namespace, client: ADTClient) -> None: """查询对象元数据信息。""" obj_type: str = args.type name: str = args.name parsed = parse_object_name(name, obj_type) type_label = get_type_config(obj_type).label INFO_ACCEPT: dict[str, str] = { "report": "application/vnd.sap.adt.programs.programs.v2+xml", "class": "application/vnd.sap.adt.oo.classes.v2+xml", "interface": "application/vnd.sap.adt.oo.interfaces.v2+xml", "function": "application/vnd.sap.adt.functions.fmodules.v2+xml", "functiongroup": "application/vnd.sap.adt.functions.groups.v2+xml", "domain": "*/*", "dataelement": "*/*", "table": "*/*", "tabletype": "*/*", "structure": "application/vnd.sap.adt.ddic.structures.v1+xml", "include": "application/vnd.sap.adt.programs.includes.v2+xml", "cdsview": "application/vnd.sap.adt.ddic.ddls.v3+xml", "messageclass": "application/vnd.sap.adt.t100.message.classes.v1+xml", "view": "application/vnd.sap.adt.ddic.views.v1+xml", "searchhelp": "application/vnd.sap.adt.ddic.searchhelps.v1+xml", "lockobject": "application/vnd.sap.adt.ddic.lockobjects.v1+xml", "dcl": "text/plain", "ddlX": "text/plain", } print("=" * 60) print(" SAP ADT 对象信息查询") print("=" * 60) print(f" 对象类型: {type_label}") print(f" 对象名称: {parsed.display_name}") print(f"\n → 正在查询对象信息...") accept = INFO_ACCEPT.get(obj_type, "application/xml") url = client.host + parsed.obj_uri hdrs = client._headers("application/xml") hdrs["Accept"] = accept logger.info("INFO: GET %s", url) resp = client.session.get(url, headers=hdrs) logger.info("INFO RESPONSE: HTTP %s", resp.status_code) if resp.status_code == 404: print(f" ✗ 对象不存在: {parsed.display_name}") raise ObjectNotFoundError(parsed.display_name, obj_type) if resp.status_code == 406: # NW 7.40 实测:DDIC 专属媒体类型返回 406,且 application/xml 本身也 406 # (structure 专属头 → 406、application/xml → 406、*/* → 200)。 # 回退必须是 */*,否则回退恒败、命令报「查询失败: HTTP 406」。 hdrs["Accept"] = "*/*" resp = client.session.get(url, headers=hdrs) if resp.status_code != 200: print(f" ✗ 查询失败: HTTP {resp.status_code}") print(f" {resp.text[:200]}") raise SapCliError(f"查询失败: HTTP {resp.status_code}") root = ET.fromstring(resp.content) ns = {"adtcore": "http://www.sap.com/adt/core"} info_name = root.attrib.get(f"{{{ns['adtcore']}}}name", "") info_type = root.attrib.get(f"{{{ns['adtcore']}}}type", "") info_desc = root.attrib.get(f"{{{ns['adtcore']}}}description", "") info_version = root.attrib.get(f"{{{ns['adtcore']}}}version", "") info_changed_at = root.attrib.get(f"{{{ns['adtcore']}}}changedAt", "") info_changed_by = root.attrib.get(f"{{{ns['adtcore']}}}changedBy", "") info_created_by = root.attrib.get(f"{{{ns['adtcore']}}}createdBy", "") info_responsible = root.attrib.get(f"{{{ns['adtcore']}}}responsible", "") info_language = root.attrib.get(f"{{{ns['adtcore']}}}masterLanguage", "") # 开发包:解析对象 XML 自带的 packageRef 子元素 pkg_ref = root.find(f".//{{{ns['adtcore']}}}packageRef") info_package = ( pkg_ref.attrib.get(f"{{{ns['adtcore']}}}name", "") if pkg_ref is not None else "" ) # NW 7.40 DDIC 的 */* 响应不含 description/packageRef:仅在 ADT 返回值为空时补查, # 补查失败静默降级(保持原样),不打断 info 输出。 if not info_desc: info_desc = _query_ddic_description( client, obj_type, parsed.display_name, _sap_language_key(info_language) ) if not info_package: info_package = _query_ddic_package(client, obj_type, parsed.display_name) if info_version == "active": status_icon = "✓ 已激活" elif info_version == "inactive": status_icon = "⚠️ 未激活" else: status_icon = f"❓ {info_version}" # 传输请求号:纯只读查 E071(不在对象元数据 XML 中) corr_display = _query_transport_request(client, obj_type, parsed.display_name) print(f" ✓ 查询成功\n") print(f" {'─' * 50}") print(f" 名称: {info_name}") print(f" 类型: {info_type}") print(f" 描述: {info_desc}") print(f" 状态: {status_icon}") print(f" 负责人: {info_responsible}") print(f" 语言: {info_language}") print(f" 开发包: {info_package or '(未指定)'}") print(f" 传输请求: {corr_display}") print(f" 创建者: {info_created_by}") print(f" 修改者: {info_changed_by}") print(f" 修改时间: {info_changed_at}") print(f" URI: {parsed.obj_uri}") print(f" {'─' * 50}") expected_name = parsed.display_name.upper().split("/")[-1] returned_name = info_name.upper() if returned_name != expected_name: print(f"\n ✗ 名称不匹配! 请求: {expected_name}, 返回: {returned_name}") print(f" 可能查询到了错误的对象") raise SapCliError(f"名称不匹配! 请求: {expected_name}, 返回: {returned_name}") def cmd_delete(args: argparse.Namespace, client: ADTClient) -> None: """从 SAP 系统删除对象。""" obj_type: str = args.type name: str = args.name parsed = parse_object_name(name, obj_type) type_label = get_type_config(obj_type).label print("=" * 60) print(" SAP ADT 源代码删除") print("=" * 60) print(f" 对象类型: {type_label}") print(f" 对象名称: {parsed.display_name}") print(f"\n → 检查对象是否存在...") if not client.object_exists(parsed.exists_uri or parsed.obj_uri): print(f" ✗ 对象不存在: {parsed.display_name}") print(f" 请确认名称和类型是否正确") raise ObjectNotFoundError(parsed.display_name, obj_type) print(" ✓ 对象存在") print(f"\n ⚠️ 即将从 SAP 系统中删除: {parsed.display_name}") print(f" 类型: {type_label}") print(f" URI: {parsed.obj_uri}") confirm = input("\n 确认删除? (输入 yes 确认): ").strip() if confirm.lower() != "yes": print(" 已取消删除操作") return corr_nr = client.get_transport_request() if corr_nr: print(f" ✓ 传输请求: {corr_nr}") else: print(" ℹ 未找到可修改的传输请求,将尝试无传输号删除") print(f"\n → 正在删除对象...") success, error = client.delete_object(parsed.obj_uri, corr_nr) if success: print(f" ✓ 删除成功!") print(f"\n ✓ {parsed.display_name} 已从 SAP 系统中删除") # 更新清单(如果适用) _update_manifest_after_delete(args, name) else: print(f" ✗ 删除失败: {error}") print(f"\n 可能原因:") print(f" - 对象被其他用户锁定") print(f" - 缺少删除权限") print(f" - 需要传输请求号") raise DeleteError(f"删除失败: {error}") def cmd_unlock(args: argparse.Namespace, client: ADTClient) -> None: """释放指定对象的 enqueue 锁(清理 delete 后残留锁 / SM12 场景)。""" obj_type: str = args.type name: str = args.name lock_handle: str = args.lock_handle parsed = parse_object_name(name, obj_type) type_label = get_type_config(obj_type).label print("=" * 60) print(" SAP ADT 释放对象锁") print("=" * 60) print(f" 对象类型: {type_label}") print(f" 对象名称: {parsed.display_name}") print(f"\n → 正在释放锁 (lockHandle={lock_handle[:12]}...)...") ok = client.unlock(parsed.obj_uri, lock_handle) if ok: print(" ✓ 锁释放成功") else: print(" ✗ 锁释放失败(HTTP 非 200,可能锁已不存在或 handle 无效)") raise SapCliError(f"解锁失败: {parsed.display_name}") def _update_manifest_after_delete( args: argparse.Namespace, name: str, ) -> None: """delete 成功后尝试从清单移除(如果适用)。""" project_path = getattr(args, "project_path", None) if not project_path: return manifest_path = os.path.join(project_path, "manifest.json") if not os.path.isfile(manifest_path): return try: manifest = Manifest.load(project_path) if manifest.remove(name): manifest.save() logger.info("清单已更新: 移除 %s", name) except Exception as e: logger.warning("更新清单失败: %s", e) def _select_transport_request(client: ADTClient) -> str | None: """交互式传输请求选择:列出已有请求或新建。 Returns: 选中的传输请求编号,或 None 表示无传输号创建。 """ print(f"\n → 查询可用的传输请求...") try: requests_list = client.list_transport_requests() except Exception as e: logger.warning("查询传输请求失败: %s", e) print(f" ℹ 查询传输请求失败,将尝试无传输号创建") return None if requests_list: print(f" 找到 {len(requests_list)} 个可修改的传输请求:\n") for i, req in enumerate(requests_list, 1): desc = req.get("description", "") owner = req.get("owner", "") print(f" {i}. {req['number']} {desc} (所有者: {owner})") print(f" {len(requests_list) + 1}. 新建传输请求") print(f" 0. 不使用传输请求(本地对象)") print() while True: choice = input(" 请选择 [0-{}]: ".format(len(requests_list) + 1)).strip() if not choice: continue try: idx = int(choice) except ValueError: print(" ✗ 请输入数字") continue if idx == 0: print(" ℹ 将尝试无传输号创建(本地对象 $TMP)") return None elif 1 <= idx <= len(requests_list): selected = requests_list[idx - 1]["number"] print(f" ✓ 已选择传输请求: {selected}") return selected elif idx == len(requests_list) + 1: # 新建传输请求 tr_desc = input(" 请输入新传输请求描述: ").strip() if not tr_desc: print(" ✗ 描述不能为空,请重新选择") continue try: new_nr = client.create_transport_request(tr_desc) if new_nr: print(f" ✓ 传输请求已创建: {new_nr}") return new_nr else: print(" ✗ 创建传输请求失败(未返回编号),将尝试无传输号创建") return None except Exception as e: print(f" ✗ 创建传输请求失败: {e}") return None else: print(f" ✗ 请输入 0-{len(requests_list) + 1} 之间的数字") else: print(" ℹ 未找到可修改的传输请求") print() choice = input(" 是否新建传输请求? (y/n): ").strip().lower() if choice in ("y", "yes"): tr_desc = input(" 请输入新传输请求描述: ").strip() if tr_desc: try: new_nr = client.create_transport_request(tr_desc) if new_nr: print(f" ✓ 传输请求已创建: {new_nr}") return new_nr except Exception as e: print(f" ✗ 创建传输请求失败: {e}") else: print(" ✗ 描述不能为空") print(" ℹ 将尝试无传输号创建") return None def cmd_create(args: argparse.Namespace, client: ADTClient) -> None: """在 SAP 系统创建开发对象。""" obj_type: str = args.type name: str = args.name description: str = getattr(args, "description", None) or name source_file: str | None = getattr(args, "source", None) definition_file: str | None = getattr(args, "definition", None) type_label = get_type_config(obj_type).label if obj_type == "function": if "/" not in name: print(f" ✗ function 类型需要'函数组名/函数模块名' 格式,例如: ZGROUP/Z_MY_FUNC") raise InvalidNameError( "function 类型需要'函数组名/函数模块名' 格式,例如: ZGROUP/Z_MY_FUNC" ) display_name = name print("=" * 60) print(" SAP ADT 创建开发对象") print("=" * 60) print(f" 对象类型: {type_label}") print(f" 对象名称: {display_name}") print(f" 描述: {description}") print(f"\n → 检查对象是否存在...") if obj_type != "functiongroup": parsed = parse_object_name(name, obj_type) if client.object_exists(parsed.exists_uri or parsed.obj_uri): print(f" ✗ 对象已存在: {display_name}") raise ObjectAlreadyExistsError(display_name, type_label) else: if client.function_group_exists(name): print(f" ✗ 函数组已存在: {name}") raise ObjectAlreadyExistsError(name, type_label) print(" ✓ 对象不存在,可以创建") # ── 传输请求选择 ── corr_nr = getattr(args, "corr_nr", None) if corr_nr: # 非交互模式:用户通过 --corr_nr 指定 print(f" ✓ 传输请求(指定): {corr_nr}") else: corr_nr = _select_transport_request(client) if obj_type == "function": group_name = name.split("/", 1)[0] if not client.function_group_exists(group_name): print(f"\n → 函数组 {group_name} 不存在,自动创建...") try: client.create_function_group(group_name, corr_nr=corr_nr) print(f" ✓ 函数组 {group_name} 创建成功") except Exception as e: print(f" ✗ 函数组创建失败: {e}") raise CreateError(f"函数组创建失败: {e}") from e else: print(f" ✓ 函数组 {group_name} 已存在") if obj_type == "functiongroup": print(f"\n → 正在创建函数组...") try: created_uri = client.create_function_group(name, description, corr_nr) except Exception as e: print(f" ✗ 创建失败: {e}") raise CreateError(str(e)) from e print(f" ✓ 函数组创建成功") print(f" ✓ URI: {created_uri}") print(f"\n ✓ {name} 创建完成!") print(f" 类型: 函数组(FUNCTION GROUP)") print(f" 描述: {description}") return if obj_type in DDIC_TYPES: _create_ddic(args, client, obj_type, name, description, corr_nr, definition_file) return if source_file: with open(source_file, "r", encoding="utf-8") as f: source = f.read() else: template_name = name.split("/", 1)[-1].upper() if obj_type == "function" else name.upper() source = DEFAULT_TEMPLATES[obj_type].format(name=template_name) print(f"\n → 正在创建对象...") try: created_uri, created_src_uri = client.create_object( obj_type, name, description, corr_nr, source ) except Exception as e: print(f" ✗ 创建失败: {e}") raise CreateError(str(e)) from e print(f" ✓ 对象创建成功!") print(f" ✓ URI: {created_uri}") if source: print(f" ✓ 源代码已写入并激活 ({len(source)} 字符)") print(f"\n ✓ {display_name} 创建完成!") print(f" 类型: {type_label}") print(f" 描述: {description}") # 更新清单(如果适用) _update_manifest_after_create(args, name, obj_type, corr_nr) def _update_manifest_after_create( args: argparse.Namespace, name: str, obj_type: str, corr_nr: str | None, ) -> None: """create 成功后尝试写入清单(如果适用)。""" from datetime import datetime, timezone project_path = getattr(args, "project_path", None) if not project_path: return manifest_path = os.path.join(project_path, "manifest.json") if not os.path.isfile(manifest_path): return try: manifest = Manifest.load(project_path) now = datetime.now(timezone.utc).isoformat(timespec="seconds") # 根据类型推断文件路径 from sapcli.scanner import DIRECTORY_TYPE_MAP type_to_dir = {v: k for k, v in DIRECTORY_TYPE_MAP.items()} dir_name = type_to_dir.get(obj_type, "") if obj_type == "function" and "/" in name: group, func = name.split("/", 1) rel_file = f"functions/{group.lower()}/{func.lower()}.abap" elif dir_name: file_base = name.split("/", 1)[-1].lower() rel_file = f"{dir_name}/{file_base}.abap" else: rel_file = "" manifest.upsert(ManifestEntry( name=name, type=obj_type, file=rel_file, system_status="active", corr_nr=corr_nr, depends_on=[], last_sync=now, last_sync_result="success", )) manifest.save() logger.info("清单已更新: 新增 %s", name) except Exception as e: logger.warning("更新清单失败: %s", e) def _create_ddic( args: argparse.Namespace, client: ADTClient, obj_type: str, name: str, description: str, corr_nr: str | None, definition_file: str | None, ) -> None: """创建 DDIC 对象(domain/dataelement/table/structure/tabletype)。""" from sapcli.ddic import ( DomainDefinition, DataElementDefinition, TableDefinition, StructureDefinition, TableTypeDefinition, TableField, ) type_label = get_type_config(obj_type).label definition_body: str | None = None if definition_file: import json with open(definition_file, "r", encoding="utf-8") as f: data = json.load(f) if obj_type == "domain": defn = DomainDefinition( datatype=data.get("datatype", "CHAR"), length=data.get("length", 10), decimals=data.get("decimals", 0), lowercase=data.get("lowercase", False), fix_values=data.get("fix_values", []), ) definition_body = defn.to_xml(name.upper(), description) elif obj_type == "dataelement": defn = DataElementDefinition( datatype=data.get("datatype", "CHAR"), length=data.get("length", 10), decimals=data.get("decimals", 0), domain_name=data.get("domain_name", ""), ) definition_body = defn.to_xml(name.upper(), description) elif obj_type == "table": fields = [ TableField( name=f.get("name", ""), type_name=f.get("type", ""), is_key=f.get("key", False), not_null=f.get("not_null", False), ) for f in data.get("fields", []) ] defn = TableDefinition( fields=fields, enhancement_category=data.get("enhancement_category", "#NOT_CLASSIFIED"), delivery_class=data.get("delivery_class", "#A"), data_maintenance=data.get("data_maintenance", "#LIMITED"), table_category=data.get("table_category", "#TRANSPARENT"), ) definition_body = defn.to_ddl(name.lower(), description) elif obj_type == "structure": fields = [ TableField( name=f.get("name", ""), type_name=f.get("type", ""), is_key=f.get("key", False), not_null=f.get("not_null", False), ) for f in data.get("fields", []) ] defn = StructureDefinition( fields=fields, enhancement_category=data.get("enhancement_category", "#NOT_CLASSIFIED"), ) definition_body = defn.to_ddl(name.lower(), description) elif obj_type == "tabletype": defn = TableTypeDefinition( line_type=data.get("line_type", ""), key_type=data.get("key_type", "#USER_DEFINED"), access_mode=data.get("access_mode", "#STANDARD"), ) definition_body = defn.to_xml(name.upper(), description) if definition_body is None: if obj_type == "domain": defn = DomainDefinition() definition_body = defn.to_xml(name.upper(), description) elif obj_type == "dataelement": defn = DataElementDefinition() definition_body = defn.to_xml(name.upper(), description) elif obj_type == "table": defn = TableDefinition(fields=[ TableField(name="key_field", type_name="char10", not_null=True), ]) definition_body = defn.to_ddl(name.lower(), description) elif obj_type == "structure": defn = StructureDefinition(fields=[ TableField(name="field1", type_name="char10"), ]) definition_body = defn.to_ddl(name.lower(), description) elif obj_type == "tabletype": print(f" ✗ tabletype 需要通过 --definition 指定 line_type") raise CreateError("tabletype 需要通过 --definition 指定 line_type") print(f"\n → 正在创建 DDIC 对象...") try: created_uri, created_src_uri = client.create_ddic_object( obj_type, name, definition_body, corr_nr ) except Exception as e: print(f" ✗ 创建失败: {e}") raise CreateError(str(e)) from e print(f" ✓ DDIC 对象创建并激活成功!") print(f" ✓ URI: {created_uri}") print(f"\n ✓ {name} 创建完成!") print(f" 类型: {type_label}") print(f" 描述: {description}")