Stage 0: SAP(ZAA_ICF)에서 패키지 단위로 프로그램 소스 수집 (ingest/from_sap.py)
- sap/: abap-mcp 의 catalog/sap_client/mcp_server 복사. import 와 .env 탐색만 이 저장소에 맞춤 - ingest/from_sap.py: 패키지명 → GET_PROGRAM_LIST → GET_PROGRAM_SOURCE(+Include) → data/raw/*.txt (normalize 가 읽는 수집 JSON 형식 그대로). 받은 파일은 건너뛰고 --force 로 재수집 - normalize: 응답의 TCODE_LIST 를 tcodes.jsonl 로 (from_dir 와 같은 모양, 로더가 적재) - .env.example 에 SAP_URL/SAP_USER/SAP_PASS, tests/test_from_sap.py Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
11ae3629b2
commit
921b8ce229
@@ -23,6 +23,13 @@ EMBED_BASE_URL=
|
|||||||
EMBED_API_KEY=
|
EMBED_API_KEY=
|
||||||
EMBED_MODEL=
|
EMBED_MODEL=
|
||||||
|
|
||||||
|
# SAP 접속 (Stage 0: python -m ingest.from_sap <패키지>) — sap/README.md
|
||||||
|
# 고객사 SAP 의 ZAA_ICF 서비스 주소. ZCL_ZAA_AGENT_API 가 설치돼 있어야 한다
|
||||||
|
SAP_URL=https://<host>:<port>/sap/bc/ZAA_ICF
|
||||||
|
SAP_USER=
|
||||||
|
SAP_PASS=
|
||||||
|
# SAP_VERIFY_SSL=1 # 사내 인증서 검증을 켜려면 (기본 꺼짐)
|
||||||
|
|
||||||
# 질의 API 서버
|
# 질의 API 서버
|
||||||
INDEX_HOST=127.0.0.1
|
INDEX_HOST=127.0.0.1
|
||||||
INDEX_PORT=8100
|
INDEX_PORT=8100
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ ABAP 소스코드 인덱싱 서버. 약 1만 본의 ABAP 프로그램 소스와
|
|||||||
## 파이프라인
|
## 파이프라인
|
||||||
|
|
||||||
```
|
```
|
||||||
|
python -m ingest.from_sap ZFI01 # Stage 0: SAP(ZAA_ICF)에서 패키지의 프로그램 소스 → data/raw (sap/ = abap-mcp 복사본)
|
||||||
data/raw/*.txt (수집 JSON)
|
data/raw/*.txt (수집 JSON)
|
||||||
→ python -m ingest.normalize # Stage 1: 줄바꿈 아티팩트 제거, 프로그램/인클루드 파일화
|
→ python -m ingest.normalize # Stage 1: 줄바꿈 아티팩트 제거, 프로그램/인클루드 파일화
|
||||||
→ python -m parser.run # Stage 2: unit·참조·데이터플로우·호출그래프·정의부 (LLM 없이)
|
→ python -m parser.run # Stage 2: unit·참조·데이터플로우·호출그래프·정의부 (LLM 없이)
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Stage 0 — SAP 에서 패키지 단위로 프로그램 소스를 받아 data/raw/ 에 저장한다.
|
||||||
|
|
||||||
|
abap-mcp 의 SAP 클라이언트(sap/sap_client.py, ZAA_ICF = ZCL_ZAA_AGENT_API)를 그대로 쓴다.
|
||||||
|
저장 형식은 `ingest/normalize.py` 가 읽는 수집 JSON 과 같다 — 그래서 받은 뒤엔 평소대로
|
||||||
|
`python -m ingest.normalize` 부터 돌리면 된다.
|
||||||
|
|
||||||
|
python -m ingest.from_sap ZFI01 # 패키지 하나
|
||||||
|
python -m ingest.from_sap ZFI01 ZCO01 ZSD01 # 여러 개
|
||||||
|
python -m ingest.from_sap ZFI01 --limit 3 # 시험: 프로그램 3본만
|
||||||
|
python -m ingest.from_sap ZFI01 --force # 이미 받은 파일도 다시 받기
|
||||||
|
python -m ingest.from_sap --pattern "ZFIR1*" # 패키지 대신 이름 패턴
|
||||||
|
|
||||||
|
접속 정보는 `.env` 의 SAP_URL / SAP_USER / SAP_PASS (sap/README.md). 고객사 SAP 에
|
||||||
|
ZAA_ICF 서비스(ZCL_ZAA_AGENT_API)가 설치돼 있어야 한다.
|
||||||
|
|
||||||
|
만드는 파일 (기본 data/raw/):
|
||||||
|
<PACKAGE>.txt 프로그램 목록 [{DEVCLASS, OBJ_NAME, TEXT, CREATED_ON, CHANGED_ON}]
|
||||||
|
<PROGRAM>.txt 프로그램 소스 {MAIN_PROGRAM, DESCRIPTION, TEXT_SYMBOL: [],
|
||||||
|
INCLUDE_PROGRAM: [{INCLUDE, SOURCE_CODE}], TCODE_LIST: [...]}
|
||||||
|
_errors.log 실패한 프로그램 (계속 진행하고 여기 기록)
|
||||||
|
|
||||||
|
- SAP 목록 API 는 SUBC 를 생략하면 실행 프로그램(1)과 모듈 풀(M)만 준다. Include 는 각 프로그램의
|
||||||
|
INCLUDE_LIST 로 따라오므로 따로 받지 않는다.
|
||||||
|
- 텍스트 심볼(TEXT-nnn)은 이 API 에 없다 → TEXT_SYMBOL 은 빈 배열 (from_dir 와 같은 한계).
|
||||||
|
- 이미 받은 프로그램(.txt 존재)은 건너뛴다. 다시 받으려면 --force.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str) -> None:
|
||||||
|
print(msg, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _call(method: str, params: dict, retries: int = 3, timeout: int = 180) -> dict:
|
||||||
|
"""sap_call + 네트워크 오류 재시도. 결과는 sap_client.sap_call 과 같은 dict."""
|
||||||
|
from sap.sap_client import sap_call
|
||||||
|
|
||||||
|
last: dict = {}
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
last = sap_call(method, params, timeout=timeout)
|
||||||
|
# error 는 네트워크/인증/파싱 실패. RETURN.TYPE=E 는 SAP 이 정상 응답한 것이라 재시도 안 함
|
||||||
|
if not last.get("error"):
|
||||||
|
return last
|
||||||
|
_log(f" ! {method} {params} 실패({attempt}/{retries}): {last['error']}")
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(2 * attempt)
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def list_programs(package: str | None = None, pattern: str | None = None,
|
||||||
|
subc: str | None = None) -> list[dict]:
|
||||||
|
"""GET_PROGRAM_LIST → [{OBJ_NAME, TEXT, SUBC, UDAT, UNAM, ...}]"""
|
||||||
|
params = {"IV_PACKAGE": package or "", "IV_PATTERN": pattern or "", "IV_SUBC": subc or ""}
|
||||||
|
res = _call("GET_PROGRAM_LIST", params)
|
||||||
|
if res.get("error"):
|
||||||
|
raise RuntimeError(res["error"])
|
||||||
|
ret = res["return"]
|
||||||
|
if ret["type"] == "E":
|
||||||
|
raise RuntimeError(f"SAP 오류: {ret['message']}")
|
||||||
|
rows = (res.get("parsed") or {}).get("RESULT") or []
|
||||||
|
if ret["type"] == "W":
|
||||||
|
_log(f" ! 목록이 잘렸습니다: {ret['message']}")
|
||||||
|
return [r for r in rows if isinstance(r, dict) and r.get("OBJ_NAME")]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_program(name: str, description: str = "") -> dict:
|
||||||
|
"""GET_PROGRAM_SOURCE(+Include) → 수집 JSON 모양의 payload. 실패하면 RuntimeError."""
|
||||||
|
res = _call("GET_PROGRAM_SOURCE", {"IV_PROGRAM": name, "IV_WITH_INCLUDE": "X"})
|
||||||
|
if res.get("error"):
|
||||||
|
raise RuntimeError(res["error"])
|
||||||
|
ret = res["return"]
|
||||||
|
if ret["type"] == "E":
|
||||||
|
raise RuntimeError(f"SAP 오류: {ret['message']}")
|
||||||
|
data = res.get("parsed") or {}
|
||||||
|
main_code = data.get("SOURCE_CODE") or ""
|
||||||
|
if not main_code.strip():
|
||||||
|
raise RuntimeError("소스가 비어 있음")
|
||||||
|
|
||||||
|
program = (data.get("PROGRAM") or name).strip().upper()
|
||||||
|
includes = [{"INCLUDE": program, "SOURCE_CODE": main_code}]
|
||||||
|
seen = {program}
|
||||||
|
for inc in data.get("INCLUDE_LIST") or []:
|
||||||
|
if not isinstance(inc, dict):
|
||||||
|
continue
|
||||||
|
inc_name = (inc.get("INCL_NAME") or "").strip().upper()
|
||||||
|
code = inc.get("SOURCE") or ""
|
||||||
|
# 이름 없음 / 빈 소스(권한 없음·삭제됨) / 중복은 뺀다
|
||||||
|
if not inc_name or inc_name in seen or not code.strip():
|
||||||
|
continue
|
||||||
|
seen.add(inc_name)
|
||||||
|
includes.append({"INCLUDE": inc_name, "SOURCE_CODE": code})
|
||||||
|
|
||||||
|
tcodes = [
|
||||||
|
{"TCODE": (t.get("TCODE") or "").strip().upper(), "TTEXT": t.get("TTEXT") or ""}
|
||||||
|
for t in data.get("TCODE_LIST") or [] if isinstance(t, dict) and t.get("TCODE")
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"MAIN_PROGRAM": program,
|
||||||
|
"DESCRIPTION": description or "",
|
||||||
|
"TEXT_SYMBOL": [],
|
||||||
|
"INCLUDE_PROGRAM": includes,
|
||||||
|
"TCODE_LIST": tcodes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def package_rows(package: str, rows: list[dict]) -> list[dict]:
|
||||||
|
"""프로그램 목록 → normalize 가 packages.jsonl 로 만드는 package_list 모양."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"DEVCLASS": package.upper(),
|
||||||
|
"OBJ_NAME": r["OBJ_NAME"].upper(),
|
||||||
|
"TEXT": r.get("TEXT") or "",
|
||||||
|
"CREATED_ON": "",
|
||||||
|
"CHANGED_ON": r.get("UDAT") or "",
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, obj) -> None:
|
||||||
|
# 한 줄 JSON. normalize 는 "줄바꿈+패딩 → 공백" 치환을 먼저 하므로 들여쓰기(indent)를 쓰면 안 된다.
|
||||||
|
path.write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
|
||||||
|
def run(packages: list[str], out_dir: Path, pattern: str | None = None, subc: str | None = None,
|
||||||
|
limit: int | None = None, force: bool = False, dry_run: bool = False) -> dict:
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stats = {"packages": 0, "listed": 0, "fetched": 0, "skipped": 0, "errors": 0, "includes": 0}
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
targets: list[tuple[str, str]] = [] # (program, description)
|
||||||
|
if packages:
|
||||||
|
for pkg in packages:
|
||||||
|
pkg = pkg.strip().upper()
|
||||||
|
if not pkg:
|
||||||
|
continue
|
||||||
|
_log(f"[{pkg}] 프로그램 목록 조회")
|
||||||
|
try:
|
||||||
|
rows = list_programs(package=pkg, pattern=pattern, subc=subc)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
stats["errors"] += 1
|
||||||
|
errors.append(f"{pkg}\tLIST\t{e}")
|
||||||
|
_log(f" ! 목록 실패: {e}")
|
||||||
|
continue
|
||||||
|
stats["packages"] += 1
|
||||||
|
_log(f" 프로그램 {len(rows)}건")
|
||||||
|
if not dry_run:
|
||||||
|
_write_json(out_dir / f"{pkg}.txt", package_rows(pkg, rows))
|
||||||
|
targets += [(r["OBJ_NAME"].upper(), r.get("TEXT") or "") for r in rows]
|
||||||
|
else:
|
||||||
|
_log(f"[패턴 {pattern}] 프로그램 목록 조회")
|
||||||
|
rows = list_programs(pattern=pattern, subc=subc)
|
||||||
|
_log(f" 프로그램 {len(rows)}건")
|
||||||
|
targets += [(r["OBJ_NAME"].upper(), r.get("TEXT") or "") for r in rows]
|
||||||
|
|
||||||
|
# 같은 프로그램이 두 패키지 목록에 있을 일은 없지만 안전하게 중복 제거
|
||||||
|
uniq: dict[str, str] = {}
|
||||||
|
for name, desc in targets:
|
||||||
|
uniq.setdefault(name, desc)
|
||||||
|
targets = list(uniq.items())
|
||||||
|
if limit:
|
||||||
|
targets = targets[:limit]
|
||||||
|
stats["listed"] = len(targets)
|
||||||
|
|
||||||
|
for i, (name, desc) in enumerate(targets, 1):
|
||||||
|
path = out_dir / f"{name}.txt"
|
||||||
|
if path.exists() and not force:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
if dry_run:
|
||||||
|
_log(f" ({i}/{len(targets)}) {name} {desc}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = fetch_program(name, desc)
|
||||||
|
except Exception as e: # noqa: BLE001 — 실패 기록 후 계속
|
||||||
|
stats["errors"] += 1
|
||||||
|
errors.append(f"{name}\tSOURCE\t{e}")
|
||||||
|
_log(f" ({i}/{len(targets)}) {name} 실패: {e}")
|
||||||
|
continue
|
||||||
|
_write_json(path, payload)
|
||||||
|
stats["fetched"] += 1
|
||||||
|
stats["includes"] += len(payload["INCLUDE_PROGRAM"])
|
||||||
|
_log(f" ({i}/{len(targets)}) {name} include {len(payload['INCLUDE_PROGRAM'])} {desc}")
|
||||||
|
|
||||||
|
if errors and not dry_run:
|
||||||
|
with (out_dir / "_errors.log").open("a", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(errors) + "\n")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Stage 0 — SAP(ZAA_ICF) 에서 패키지의 프로그램 소스를 data/raw 로 받는다",
|
||||||
|
epilog="받은 뒤: python -m ingest.normalize && python -m parser.run && python -m index.loader",
|
||||||
|
)
|
||||||
|
ap.add_argument("packages", nargs="*", help="패키지명 (예: ZFI01). 여러 개 가능")
|
||||||
|
ap.add_argument("--pattern", default=None, help="프로그램명 패턴 (예: ZFIR1*). 패키지와 같이 쓰면 그 안에서 필터")
|
||||||
|
ap.add_argument("--subc", default=None, help="프로그램 유형. 생략=1(실행)+M(모듈풀). 1/M/I/F/K")
|
||||||
|
ap.add_argument("--out", default=None, help="저장 디렉토리 (기본 data/raw)")
|
||||||
|
ap.add_argument("--limit", type=int, default=None, help="받을 프로그램 수 상한 (시험용)")
|
||||||
|
ap.add_argument("--force", action="store_true", help="이미 받은 파일도 다시 받는다")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="목록만 보고 저장하지 않는다")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.packages and not args.pattern:
|
||||||
|
ap.error("패키지명 또는 --pattern 중 하나는 있어야 합니다")
|
||||||
|
|
||||||
|
from config.settings import settings
|
||||||
|
from sap.sap_client import CONFIG
|
||||||
|
|
||||||
|
if not CONFIG["user"] or not CONFIG["password"]:
|
||||||
|
print("SAP_USER / SAP_PASS 가 비어 있습니다 — .env 에 채우세요 (sap/README.md)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
_log(f"SAP: {CONFIG['base_url']} 사용자 {CONFIG['user']}")
|
||||||
|
|
||||||
|
out_dir = Path(args.out) if args.out else settings.data_raw
|
||||||
|
stats = run(args.packages, out_dir, pattern=args.pattern, subc=args.subc,
|
||||||
|
limit=args.limit, force=args.force, dry_run=args.dry_run)
|
||||||
|
print(json.dumps(stats, ensure_ascii=False))
|
||||||
|
if stats["errors"]:
|
||||||
|
print(f"실패 {stats['errors']}건 → {out_dir / '_errors.log'}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -95,6 +95,7 @@ def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool =
|
|||||||
|
|
||||||
stats = {"files": 0, "programs": 0, "includes": 0, "package_rows": 0, "total_lines": 0, "errors": 0}
|
stats = {"files": 0, "programs": 0, "includes": 0, "package_rows": 0, "total_lines": 0, "errors": 0}
|
||||||
package_rows: list[dict] = []
|
package_rows: list[dict] = []
|
||||||
|
tcode_rows: list[dict] = [] # ingest/from_sap.py 가 실어 보내는 TCODE_LIST → tcodes.jsonl
|
||||||
error_lines: list[str] = []
|
error_lines: list[str] = []
|
||||||
|
|
||||||
files = sorted(p for p in raw_dir.rglob("*") if p.is_file() and p.suffix.lower() in {".txt", ".json"})
|
files = sorted(p for p in raw_dir.rglob("*") if p.is_file() and p.suffix.lower() in {".txt", ".json"})
|
||||||
@@ -132,6 +133,11 @@ def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool =
|
|||||||
stats["programs"] += 1
|
stats["programs"] += 1
|
||||||
stats["includes"] += len(meta["includes"])
|
stats["includes"] += len(meta["includes"])
|
||||||
stats["total_lines"] += meta["total_lines"]
|
stats["total_lines"] += meta["total_lines"]
|
||||||
|
for t in data.get("TCODE_LIST") or []:
|
||||||
|
tcode = clean_text_field(t.get("TCODE")).upper()
|
||||||
|
if tcode:
|
||||||
|
tcode_rows.append({"tcode": tcode, "program": meta["program"],
|
||||||
|
"text_ko": clean_text_field(t.get("TTEXT")) or meta["description"]})
|
||||||
else:
|
else:
|
||||||
stats["errors"] += 1
|
stats["errors"] += 1
|
||||||
error_lines.append(f"{path}\tUNKNOWN_FORMAT")
|
error_lines.append(f"{path}\tUNKNOWN_FORMAT")
|
||||||
@@ -141,6 +147,10 @@ def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool =
|
|||||||
with packages_path.open("w", encoding="utf-8") as f:
|
with packages_path.open("w", encoding="utf-8") as f:
|
||||||
for row in package_rows:
|
for row in package_rows:
|
||||||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||||
|
if tcode_rows:
|
||||||
|
# from_dir.py 와 같은 파일·모양. 로더(index/loader.py load_tcodes)가 tcode 테이블에 넣는다
|
||||||
|
(out_dir / "tcodes.jsonl").write_text(
|
||||||
|
"".join(json.dumps(r, ensure_ascii=False) + "\n" for r in tcode_rows), encoding="utf-8")
|
||||||
if error_lines:
|
if error_lines:
|
||||||
errors_log.write_text("\n".join(error_lines) + "\n", encoding="utf-8")
|
errors_log.write_text("\n".join(error_lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ postgres = ["psycopg[binary]>=3.1", "pgvector>=0.2"]
|
|||||||
|
|
||||||
# flat-layout 자동 탐색이 data/ wiki/ 까지 패키지로 오인해 실패한다 — 명시 지정.
|
# flat-layout 자동 탐색이 data/ wiki/ 까지 패키지로 오인해 실패한다 — 명시 지정.
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["config", "ingest", "parser", "index", "summarize", "query", "wiki_out", "eval"]
|
packages = ["config", "ingest", "parser", "index", "summarize", "query", "wiki_out", "eval", "sap"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# ZAA_ICF MCP 서버 (abap-sap)
|
||||||
|
|
||||||
|
SAP `ZCL_ZAA_AGENT_API` 의 16개 메서드를 LLM 에 붙이는 MCP 서버. 전부 조회만 한다.
|
||||||
|
브라우저에서 같은 API 를 호출해 보는 웹 테스터와 Bruno 연동은 형제 프로젝트 **`../abap-api-tester`** 에 있다.
|
||||||
|
API 정의(`catalog.py`)와 SAP 호출 클라이언트(`sap_client.py`)는 이 폴더가 원본이고, 테스터가 여기서 가져다 쓴다.
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```
|
||||||
|
cp .env.example .env # SAP_USER / SAP_PASS 채우기
|
||||||
|
pip install -r requirements.txt # mcp>=2.1 (Python 3.10+)
|
||||||
|
python mcp_server.py # stdio (Claude Code / Claude Desktop)
|
||||||
|
python mcp_server.py --http 8766 # streamable-http http://127.0.0.1:8766/mcp
|
||||||
|
python tools/test_mcp.py [--shrink] # 툴 목록 + 실제 호출 점검
|
||||||
|
```
|
||||||
|
|
||||||
|
접속 정보는 `.env` 의 `SAP_URL` / `SAP_USER` / `SAP_PASS`. `.env` 는 스크립트 위치 기준으로 읽으므로 작업 디렉터리와 무관하다.
|
||||||
|
로그는 stderr 로만 나간다 (stdout 은 프로토콜 채널이라 print 금지).
|
||||||
|
|
||||||
|
## 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `mcp_server.py` | MCP 서버. `catalog.py` 의 16개 메서드를 툴로 노출 (stdio / streamable-http) |
|
||||||
|
| `catalog.py` | 메서드 설명, 파라미터, 샘플, 필드 사전, 코드값 맵, 셀 링크. **테스터와 공유 (원본)** |
|
||||||
|
| `sap_client.py` | SAP 호출·응답 정규화(BOM/utf-16 선언/제어문자 제거, asXML→JSON). **테스터와 공유 (원본)** |
|
||||||
|
| `.mcp.json` | Claude Code 프로젝트 MCP 설정 (이 폴더를 열면 `abap-sap` 서버가 등록됨) |
|
||||||
|
| `tools/test_mcp.py` | MCP 서버 점검 (툴 목록 + 실제 호출 + 잘라내기) |
|
||||||
|
|
||||||
|
카탈로그를 고치면 툴 정의도, 테스터 화면도, Bruno 컬렉션(테스터가 생성)도 같이 바뀐다.
|
||||||
|
|
||||||
|
## 연결
|
||||||
|
|
||||||
|
- **Claude Code**: 이 폴더에 `.mcp.json` 이 있어 프로젝트를 열면 `abap-sap` 서버가 등록된다 (처음 한 번 승인).
|
||||||
|
다른 폴더에서도 쓰려면 사용자 범위로 등록한다.
|
||||||
|
```
|
||||||
|
claude mcp add --transport stdio --scope user abap-sap -- python C:\Users\<me>\EdgeCenter\samsung\abap-mcp\mcp_server.py
|
||||||
|
```
|
||||||
|
- **Claude Desktop**: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||||
|
```json
|
||||||
|
{"mcpServers": {"abap-sap": {"type": "stdio", "command": "python",
|
||||||
|
"args": ["C:\Users\<me>\EdgeCenter\samsung\abap-mcp\mcp_server.py"]}}}
|
||||||
|
```
|
||||||
|
`.env` 대신 환경변수로 넘기려면 `"env": {"SAP_USER": "...", "SAP_PASS": "..."}`.
|
||||||
|
|
||||||
|
## 툴
|
||||||
|
|
||||||
|
툴 정의(이름·설명·파라미터·예시)는 `catalog.py` 에서 **자동 생성**된다.
|
||||||
|
|
||||||
|
| 툴 | 비고 |
|
||||||
|
|---|---|
|
||||||
|
| `get_package_list` … `get_tcode_info`, `get_object_type` (17개, 메서드명 소문자) | 파라미터는 `IV_` 를 뗀 소문자 (`IV_MAX_ROWS` → `max_rows`). 플래그는 boolean, 날짜는 `YYYY-MM-DD` |
|
||||||
|
| `explain_fields` | 응답 필드의 한국어 뜻과 코드값 표 (`catalog.py` 의 FIELDS / VALUE_MAPS) |
|
||||||
|
| `sap_connection_info` | 접속 대상·호출 규약 확인 (비밀번호 제외) |
|
||||||
|
|
||||||
|
응답은 `{"return": {"type", "message", "total_rows"}, …결과, "_meta": {sap_method, sap_params, ms, raw_bytes}}`.
|
||||||
|
`RETURN.TYPE=E` 도 툴 오류가 아니라 정상 응답으로 돌려준다 (LLM 이 메시지를 읽고 판단한다). 네트워크·인증·XML 파싱 실패만 툴 오류.
|
||||||
|
서버 `instructions` 에 탐색 흐름(패키지 → 오브젝트 → 소스, T코드 → 프로그램, 용어 → 필드)과 응답 읽는 법이 들어 있다.
|
||||||
|
|
||||||
|
### LLM 컨텍스트 보호
|
||||||
|
|
||||||
|
| 동작 | 기본값 | 환경변수 |
|
||||||
|
|---|---|---|
|
||||||
|
| 목록 툴에서 `max_rows` 생략 시 상한. 잘리면 `return.type=W` + `total_rows`. `0` 이면 전건(SAP 안전 상한 50,000) | 200 | `ABAP_MCP_DEFAULT_MAX_ROWS` |
|
||||||
|
| 응답 JSON 최대 문자 수. 넘치면 가장 큰 소스/목록부터 절반씩 줄이고 `_truncated` 에 무엇을 얼마나 잘랐는지 적는다 | 120,000 | `ABAP_MCP_MAX_OUTPUT_CHARS` |
|
||||||
|
| SAP 호출 타임아웃(초) | 180 | `ABAP_MCP_TIMEOUT` |
|
||||||
|
|
||||||
|
소스를 주는 툴(`get_program_source`, `get_version_source`, `get_function_detail`)에는 `line_from` / `line_to` 가 추가돼 있어
|
||||||
|
긴 소스를 나눠 읽는다. 응답에 `SOURCE_CODE_LINES`(전체 줄 수)와 `SOURCE_CODE_RANGE` 가 붙는다. Include/화면 안의 소스도 같은 범위로 잘린다.
|
||||||
|
|
||||||
|
실측(2026-09-07): `SAPMV45A` + Include(원문 4.2MB, include 1,822개)를 상한 15,000자로 부르면 14,378자 + 안내 3줄로 돌아온다.
|
||||||
|
|
||||||
|
## 호출 규약 (실측)
|
||||||
|
|
||||||
|
- `POST /sap/bc/ZAA_ICF/{METHOD}`, `Content-Type: application/json`, 바디 `{"INPUT": {"IV_...": ...}}`
|
||||||
|
- 응답 asXML. XML 선언은 `utf-16` 이지만 실제 UTF-8 → 선언 제거 후 파싱
|
||||||
|
- 소스코드 안 제어문자(0x0C)가 XML 을 깨뜨림 → 파싱 전 제거
|
||||||
|
- 날짜는 `YYYY-MM-DD`
|
||||||
|
- 목록 API 의 `IV_MAX_ROWS` 는 생략하면 전건(안전 상한 50,000). 지정해서 잘리면 `RETURN.TYPE=W`, 메시지에 `(전체 M건)`, `RETURN.TOTAL_ROWS=M` (2026-09-04 A안. 그 전엔 기본 200/최대 2,000)
|
||||||
|
|
||||||
|
## 서버측 결함 이력 (2026-09-04 기준 모두 해결)
|
||||||
|
|
||||||
|
- ~~`GET_VERSION_SOURCE` 과거 버전 → HTTP 500~~ 2026-09-04 해결. `SVRS_GET_REPS_FROM_OBJECT` 로 교체 (REPS 만 지원)
|
||||||
|
- ~~`GET_FUNCTION_DETAIL` 의 `FUNC_SOURCE` 항상 빈값~~ 2026-09-04 해결. 함수 Include 를 통째로 읽도록 교체 (출처가 `RETURN.MESSAGE` 에 찍힘)
|
||||||
|
- ~~`GET_PROGRAM_SOURCE` include 목록에 클래스 include 노이즈 혼입~~ 2026-09-04 해결. `=` 포함 또는 30자 초과 이름 제거
|
||||||
|
- ~~`GET_OBJECT_LIST_BY_PACKAGE` 상한에서 잘려도 `TYPE=S`~~ 2026-09-04 해결. n+1 건을 읽어 초과 시 W (실측 ZFI01 PROG 1,132건)
|
||||||
|
- ~~`GET_FIELD_LIST_BY_TEXT` USED_TABLES 20건 상한이 조용히 잘림 / 후보 0건이면 응답 본문 0바이트~~ 2026-09-04 해결. W + 잘린 엘리먼트 이름, 0건은 빈 RESULT XML
|
||||||
|
|
||||||
|
- ~~목록 API 기본 상한 200 / 최대 2,000~~ 2026-09-04 A안으로 제거. 생략 시 전건(안전 상한 50,000), `RETURN.TOTAL_ROWS` 추가. 실측: 테이블 `*` 전건 822,899건 → 50,000건 W, 8.7MB, 8초
|
||||||
|
|
||||||
|
- ~~`GET_FIELD_LIST_BY_TEXT` USED_TABLES 엘리먼트당 20건 고정 상한~~ 2026-09-04 제거. 후보 500개씩 청크 조회, 사용 테이블 총량 50,000 넘으면 중단 + W. 실측 '회사코드' 전건 499후보/6,949건 0.8MB, '코드' 14,000후보 → 안전 상한 W 10MB/4초
|
||||||
|
|
||||||
|
수정 소스는 `~/Downloads/CLASS ZCL_ZAA_AGENT_API DEFINITION.txt` (전체) 와 `METHOD *.txt`. API 명세서(메서드당 1시트)는 `~/Downloads/ABAP_API_명세서_ZAA_ICF_v1.4_2026-09-04.xlsx` (생성기는 `../abap-api-tester/tools/spec/`).
|
||||||
|
|
||||||
|
## 응답 인코딩 (2026-09-04 확인)
|
||||||
|
|
||||||
|
응답 HTTP 헤더는 `Content-Type: text/html; charset=utf-8` 이고 본문은 UTF-8 BOM 으로 시작한다.
|
||||||
|
XML 선언만 `encoding="utf-16"` 으로 잘못 찍혀 있다. Bruno 는 헤더 charset 으로 디코딩하므로 Bruno 단계에서는 문제없고,
|
||||||
|
직접 파싱하는 클라이언트만 BOM 과 XML 선언을 떼고 UTF-8 로 읽으면 된다.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""SAP ZAA_ICF 클라이언트 (abap-mcp 에서 옮겨옴). `sap.sap_client.sap_call` 이 진입점.
|
||||||
|
|
||||||
|
원본은 ../abap-mcp — catalog.py / sap_client.py / mcp_server.py 를 그대로 복사했고,
|
||||||
|
sap_client 의 import 와 .env 탐색만 이 저장소 구조에 맞게 손봤다 (아래 두 곳 '# abap-indexing' 주석).
|
||||||
|
"""
|
||||||
+549
@@ -0,0 +1,549 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""ZAA_ICF API 카탈로그: 메서드 설명, 파라미터, 입력 샘플, 응답 필드 사전, 코드값 해석.
|
||||||
|
|
||||||
|
SAP 을 모르는 개발자가 응답을 읽을 수 있도록 한국어 설명을 붙였다.
|
||||||
|
값은 2026-09-03 실제 서버 응답으로 검증한 것.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 응답에서 "빈 값이면 [] 로 취급" 할 테이블 필드
|
||||||
|
LIST_FIELDS = {
|
||||||
|
"RESULT", "INCLUDE_LIST", "SCREEN_LIST", "TCODE_LIST",
|
||||||
|
"IMPORT_PARAMETER", "EXPORT_PARAMETER", "CHANGING_PARAMETER",
|
||||||
|
"TABLES_PARAMETER", "EXCEPTION_LIST", "FIX_VALUES", "USED_TABLES", "MASTERS",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 소스코드처럼 길게 표시할 필드
|
||||||
|
CODE_FIELDS = ["SOURCE_CODE", "FUNC_SOURCE", "SOURCE", "FLOW_LOGIC"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 필드 사전 (응답 컬럼)
|
||||||
|
FIELDS = {
|
||||||
|
# 공통
|
||||||
|
"RETURN": ("처리 결과", "TYPE(S/W/E) 와 MESSAGE. W 는 결과가 상한에서 잘렸다는 뜻이 대부분."),
|
||||||
|
"RESULT": ("결과 목록", "조회 결과 행 목록"),
|
||||||
|
"TYPE": ("결과 유형", "S=성공, W=경고(결과 잘림 등), E=오류(필수값 누락/미존재)"),
|
||||||
|
"MESSAGE": ("메시지", "사람이 읽는 결과 설명"),
|
||||||
|
"TOTAL_ROWS": ("전체 건수", "절삭 전 전체 건수. IV_MAX_ROWS 가 있는 목록 API 만 채움 (그 외 0). 잘리지 않았으면 RESULT 건수와 같음"),
|
||||||
|
"KIND": ("오브젝트 세분류", "REPORT=실행 프로그램, MODULE_POOL=모듈 풀, INCLUDE=Include, FUGR_MAIN=함수그룹 메인(SAPL*), CLASS_POOL/INTERFACE_POOL/TYPE_POOL=클래스·인터페이스·타입 풀, TABLE/STRUCTURE/APPEND/VIEW/TABLE_TYPE/DATA_ELEMENT/DOMAIN=DDIC, CLASS/INTERFACE/FUNCTION_GROUP/FUNCTION/TCODE"),
|
||||||
|
"MASTERS": ("메인 프로그램 목록", "Include 를 포함하는 메인 프로그램(D010INC). KIND=INCLUDE 일 때만 채움"),
|
||||||
|
"SPRAS": ("텍스트 언어", "설명 텍스트가 어떤 언어인지. 3=한국어, E=영어, D=독일어"),
|
||||||
|
"SPRSL": ("텍스트 언어", "설명 텍스트가 어떤 언어인지. 3=한국어, E=영어, D=독일어"),
|
||||||
|
"LANG_PRIO": ("언어 우선순위", "1=로그인 언어(한국어), 2=영어, 3=독일어, 9=기타. 여러 언어 중 이 순서로 하나만 남김"),
|
||||||
|
# 패키지
|
||||||
|
"DEVCLASS": ("패키지", "개발 오브젝트를 묶는 폴더 개념(개발 클래스). Z/Y 로 시작하면 고객 개발"),
|
||||||
|
"CTEXT": ("패키지 설명", "패키지 짧은 설명"),
|
||||||
|
"PARENTCL": ("상위 패키지", "패키지 계층의 부모. 비어 있으면 최상위"),
|
||||||
|
# 프로그램
|
||||||
|
"OBJ_NAME": ("오브젝트 이름", "프로그램/클래스/테이블 등의 기술적 이름"),
|
||||||
|
"OBJECT": ("오브젝트 유형", "PROG=프로그램, FUGR=함수그룹, CLAS=클래스, TABL=테이블/구조, DTEL=데이터엘리먼트, TRAN=트랜잭션 등"),
|
||||||
|
"AUTHOR": ("담당자", "오브젝트 책임자(생성자) 사용자 ID"),
|
||||||
|
"TEXT": ("제목", "프로그램 제목 텍스트"),
|
||||||
|
"SUBC": ("프로그램 유형", "1=실행 프로그램(리포트), M=모듈 풀(화면 프로그램), I=Include, F=함수그룹, K=클래스 풀"),
|
||||||
|
"UDAT": ("최종 변경일", "마지막으로 소스를 바꾼 날짜"),
|
||||||
|
"UNAM": ("최종 변경자", "마지막으로 소스를 바꾼 사용자"),
|
||||||
|
"PROGRAM": ("프로그램", "조회한 프로그램 이름"),
|
||||||
|
"SOURCE_CODE": ("소스 코드", "ABAP 소스 전문(줄바꿈 포함)"),
|
||||||
|
"SOURCE_SIZE": ("소스 길이", "SOURCE_CODE 의 문자 수"),
|
||||||
|
"INCLUDE_LIST": ("Include 목록", "프로그램이 끌어다 쓰는 별도 소스 파일(Include)과 그 내용"),
|
||||||
|
"INCL_NAME": ("Include 이름", "Include 프로그램 이름. 클래스/Enhancement 시스템 include(31~32자, '=' 포함)는 서버가 걸러낸다"),
|
||||||
|
"SOURCE": ("소스", "해당 Include 의 ABAP 소스"),
|
||||||
|
"SCREEN_LIST": ("화면 목록", "프로그램에 붙은 화면(Dynpro)과 화면 흐름 로직"),
|
||||||
|
"SCREEN_NUMBER": ("화면 번호", "4자리 화면 번호 (예: 0100)"),
|
||||||
|
"FLOW_LOGIC": ("화면 흐름 로직", "PBO(표시 전)/PAI(입력 후) 에 실행되는 모듈 호출 코드"),
|
||||||
|
"TCODE_LIST": ("트랜잭션 코드", "이 프로그램을 실행하는 트랜잭션 코드(SAP 메뉴에서 치는 명령어)"),
|
||||||
|
"TCODE": ("트랜잭션 코드", "SAP 화면에서 입력하는 실행 명령어 (예: SE38, VA01)"),
|
||||||
|
"TTEXT": ("트랜잭션 설명", "트랜잭션 코드 텍스트"),
|
||||||
|
"PGMNA": ("프로그램", "트랜잭션이 실행하는 프로그램 이름"),
|
||||||
|
# 함수
|
||||||
|
"FUNCNAME": ("함수 모듈", "재사용 가능한 ABAP 함수(Function Module) 이름"),
|
||||||
|
"STEXT": ("함수 설명", "함수 짧은 설명"),
|
||||||
|
"FUNC_TEXT": ("함수 설명", "함수 짧은 설명"),
|
||||||
|
"FUGR_NAME": ("함수 그룹", "함수 모듈이 속한 그룹. 메인 프로그램은 SAPL<그룹>, TOP include 는 L<그룹>TOP"),
|
||||||
|
"FUNC_SOURCE": ("함수 소스", "함수 본문 소스 전문 (FUNCTION ~ ENDFUNCTION). 함수 Include 를 통째로 읽은 것"),
|
||||||
|
"IMPORT_PARAMETER": ("입력 파라미터", "호출자가 함수에 넘기는 값"),
|
||||||
|
"EXPORT_PARAMETER": ("출력 파라미터", "함수가 돌려주는 값"),
|
||||||
|
"CHANGING_PARAMETER": ("변경 파라미터", "넘겨서 받아오는(입출력) 값"),
|
||||||
|
"TABLES_PARAMETER": ("테이블 파라미터", "내부 테이블(행 목록)로 주고받는 값"),
|
||||||
|
"EXCEPTION_LIST": ("예외 목록", "함수가 실패 시 던지는 예외 이름"),
|
||||||
|
"PARAMETER": ("파라미터 이름", "함수 인터페이스 파라미터 이름"),
|
||||||
|
"DBFIELD": ("참조 타입", "파라미터 타입으로 참조하는 데이터엘리먼트/구조"),
|
||||||
|
"TYP": ("타입", "TYPE 절로 지정한 타입"),
|
||||||
|
"DEFAULT": ("기본값", "생략 시 사용되는 값"),
|
||||||
|
"OPTIONAL": ("선택 여부", "X=생략 가능"),
|
||||||
|
"REFERENCE": ("참조 전달", "X=값 복사 없이 참조로 전달"),
|
||||||
|
"DBSTRUCT": ("행 구조", "테이블 파라미터 한 행의 구조"),
|
||||||
|
"EXCEPTION": ("예외", "예외 이름"),
|
||||||
|
# 테이블
|
||||||
|
"TABNAME": ("테이블", "DB 테이블 또는 구조 이름"),
|
||||||
|
"DDTEXT": ("설명", "사전(Data Dictionary) 설명 텍스트"),
|
||||||
|
"TABCLASS": ("테이블 종류", "TRANSP=실제 DB 테이블, INTTAB=구조(데이터 없음), VIEW=뷰, CLUSTER/POOL=특수 테이블"),
|
||||||
|
"IS_CUSTOM": ("고객 개발", "X=Z/Y 로 시작하는 고객 개발 테이블"),
|
||||||
|
"FIELDNAME": ("필드", "테이블 컬럼 이름"),
|
||||||
|
"KEYFLAG": ("키 여부", "X=기본 키(Primary Key) 필드"),
|
||||||
|
"POSITION": ("순서", "테이블 안에서의 컬럼 순서"),
|
||||||
|
"ROLLNAME": ("데이터엘리먼트", "필드의 의미(설명·라벨)를 정의하는 재사용 타입. 도메인 위에 얹힘"),
|
||||||
|
"DOMNAME": ("도메인", "필드의 기술 속성(타입·길이·허용값)을 정의하는 재사용 타입"),
|
||||||
|
"DATATYPE": ("데이터 타입", "CHAR=문자, NUMC=숫자문자, DATS=날짜, TIMS=시간, DEC=소수, CURR=금액, QUAN=수량, INT4=정수, CLNT=클라이언트"),
|
||||||
|
"LENG": ("길이", "필드 길이(문자 수 또는 자릿수)"),
|
||||||
|
"DECIMALS": ("소수 자릿수", "소수점 이하 자릿수"),
|
||||||
|
"OUTPUTLEN": ("출력 길이", "화면 표시 길이"),
|
||||||
|
"CHECKTABLE": ("체크 테이블", "외래키로 값 유효성을 검사하는 참조 테이블"),
|
||||||
|
"SCRTEXT_S": ("짧은 라벨", "화면 표시용 라벨(짧음)"),
|
||||||
|
"SCRTEXT_M": ("중간 라벨", "화면 표시용 라벨(중간)"),
|
||||||
|
"SCRTEXT_L": ("긴 라벨", "화면 표시용 라벨(김)"),
|
||||||
|
"ELEMENT": ("데이터엘리먼트 정보", "입력이 데이터엘리먼트인 경우 채워짐"),
|
||||||
|
"DOMAIN": ("도메인 정보", "데이터엘리먼트의 도메인 또는 입력이 도메인인 경우"),
|
||||||
|
"LOWERCASE": ("소문자 허용", "X=대소문자 구분 저장(기본은 대문자 변환)"),
|
||||||
|
"CONVEXIT": ("변환 루틴", "저장값과 표시값을 바꾸는 루틴 (예: ALPHA=앞자리 0 채움)"),
|
||||||
|
"VALEXI": ("고정값 존재", "X=도메인에 허용값 목록(FIX_VALUES)이 있음"),
|
||||||
|
"ENTITYTAB": ("값 테이블", "허용값을 관리하는 테이블 (예: MTART → T134)"),
|
||||||
|
"FIX_VALUES": ("고정값 목록", "도메인에 정의된 허용 값과 설명"),
|
||||||
|
"DOMVALUE_L": ("값", "허용 값"),
|
||||||
|
"USED_TABLES": ("사용 테이블", "이 데이터엘리먼트를 컬럼 타입으로 쓰는 테이블/구조 전부 (총량 안전 상한 50,000)"),
|
||||||
|
# CTS
|
||||||
|
"TRKORR": ("전송 요청 번호", "변경 사항 묶음(Transport Request) 번호. 개발→운영 이관 단위"),
|
||||||
|
"AS4TEXT": ("요청 설명", "전송 요청 제목"),
|
||||||
|
"AS4USER": ("소유자", "전송 요청을 만든 사용자"),
|
||||||
|
"TRFUNCTION": ("요청 유형", "K=워크벤치(프로그램 등 개발물), W=커스터마이징(설정값), T=전송 대상 없음"),
|
||||||
|
"AS4DATE": ("변경일", "요청 마지막 변경(릴리즈) 날짜"),
|
||||||
|
"AS4TIME": ("변경 시각", "요청 마지막 변경(릴리즈) 시각"),
|
||||||
|
"PGMID": ("프로그램 ID", "R3TR=오브젝트 전체, LIMU=오브젝트 일부(메서드/소스 등), CORR=전송 관리 항목"),
|
||||||
|
# 버전
|
||||||
|
"OBJNAME": ("오브젝트 이름", "버전 관리 대상 이름"),
|
||||||
|
"OBJTYPE": ("버전 오브젝트 유형", "REPS=프로그램/Include 소스, CLSD=클래스 정의, METH=메서드, FUNC=함수"),
|
||||||
|
"VERSNO": ("버전 번호", "00000=활성(현재) 버전, 그 외는 과거 버전. 클수록 최신"),
|
||||||
|
"KORRNUM": ("전송 요청", "이 버전을 만든 전송 요청 번호"),
|
||||||
|
"DATUM": ("버전 생성일", "버전이 저장된 날짜"),
|
||||||
|
"ZEIT": ("버전 생성 시각", "버전이 저장된 시각"),
|
||||||
|
# 사용처
|
||||||
|
"MASTER": ("사용/메인 프로그램", "where-used: 해당 오브젝트를 사용하는 프로그램. GET_OBJECT_TYPE: Include 를 포함하는 컴파일 단위(함수그룹이면 SAPL<그룹>, 클래스면 클래스 풀)"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 코드값 해석
|
||||||
|
VALUE_MAPS = {
|
||||||
|
"TYPE": {"S": "성공", "W": "경고", "E": "오류", "X": "호출 실패"},
|
||||||
|
"SPRAS": {"3": "한국어", "E": "영어", "D": "독일어", "J": "일본어", "1": "중국어(간체)", "": "(텍스트 없음)"},
|
||||||
|
"SPRSL": {"3": "한국어", "E": "영어", "D": "독일어", "J": "일본어", "1": "중국어(간체)", "": "(텍스트 없음)"},
|
||||||
|
"LANG_PRIO": {"1": "로그인 언어", "2": "영어", "3": "독일어", "9": "기타"},
|
||||||
|
"SUBC": {"1": "실행 프로그램(리포트)", "M": "모듈 풀(화면 프로그램)", "I": "Include", "F": "함수 그룹",
|
||||||
|
"K": "클래스 풀", "J": "인터페이스 풀", "S": "서브루틴 풀", "T": "타입 풀", "X": "XSLT"},
|
||||||
|
"OBJECT": {
|
||||||
|
"PROG": "프로그램", "FUGR": "함수 그룹", "CLAS": "클래스", "INTF": "인터페이스",
|
||||||
|
"TABL": "테이블/구조", "DTEL": "데이터엘리먼트", "DOMA": "도메인", "TTYP": "테이블 타입",
|
||||||
|
"VIEW": "뷰", "DDLS": "CDS 뷰", "SHLP": "검색 도움말", "ENQU": "잠금 오브젝트",
|
||||||
|
"TRAN": "트랜잭션 코드", "MSAG": "메시지 클래스", "DEVC": "패키지", "XSLT": "XSLT 변환",
|
||||||
|
"SXCI": "BAdI 구현", "ENHO": "인핸스먼트 구현", "ENHS": "인핸스먼트 스팟", "WDYN": "Web Dynpro",
|
||||||
|
"SFPF": "Adobe 폼", "SFPI": "Adobe 인터페이스", "SSFO": "Smart Form", "FORM": "SAPscript 폼",
|
||||||
|
"SMIM": "MIME 오브젝트", "NROB": "번호 범위", "TOBJ": "유지보수 뷰", "PARA": "SET/GET 파라미터",
|
||||||
|
"IWSV": "OData 서비스", "IWPR": "OData 프로젝트", "SRVD": "서비스 정의", "SRVB": "서비스 바인딩",
|
||||||
|
"BDEF": "비헤이비어 정의", "DCLS": "접근 제어(DCL)", "RELE": "릴리즈 마커", "TABU": "테이블 데이터",
|
||||||
|
"VDAT": "뷰 데이터", "CDAT": "커스터마이징 데이터", "TDAT": "테이블 데이터(그룹)",
|
||||||
|
"REPS": "프로그램 소스", "METH": "메서드", "FUNC": "함수 모듈", "DYNP": "화면", "CUAD": "GUI 상태",
|
||||||
|
"CINC": "클래스 로컬 include", "CPUB": "클래스 public 섹션", "CPRI": "클래스 private 섹션",
|
||||||
|
"CPRO": "클래스 protected 섹션", "CLSD": "클래스 정의", "REPT": "프로그램 텍스트",
|
||||||
|
"TABD": "테이블 정의", "DTED": "데이터엘리먼트 정의", "DOMD": "도메인 정의", "MESS": "메시지",
|
||||||
|
},
|
||||||
|
"PGMID": {"R3TR": "오브젝트 전체", "LIMU": "오브젝트 일부", "CORR": "전송 관리 항목"},
|
||||||
|
"TRFUNCTION": {"K": "워크벤치 요청", "W": "커스터마이징 요청", "T": "대상 없는 전송", "C": "재배치(패키지 변경 없음)",
|
||||||
|
"O": "재배치(패키지 변경)", "E": "재배치(패키지)", "S": "개발/수정 태스크", "R": "리페어",
|
||||||
|
"X": "미분류 태스크", "Q": "커스터마이징 태스크", "L": "삭제 전송", "D": "패치", "P": "사전 준비"},
|
||||||
|
"TABCLASS": {"TRANSP": "투명 테이블(실제 DB)", "INTTAB": "구조(데이터 없음)", "VIEW": "뷰", "CLUSTER": "클러스터 테이블",
|
||||||
|
"POOL": "풀 테이블", "APPEND": "어펜드 구조"},
|
||||||
|
"DATATYPE": {"CHAR": "문자", "NUMC": "숫자 문자열", "DATS": "날짜(YYYYMMDD)", "TIMS": "시간(HHMMSS)", "DEC": "소수(packed)",
|
||||||
|
"CURR": "통화 금액", "CUKY": "통화 키", "QUAN": "수량", "UNIT": "단위", "INT1": "1바이트 정수", "INT2": "2바이트 정수",
|
||||||
|
"INT4": "4바이트 정수", "INT8": "8바이트 정수", "FLTP": "부동소수", "RAW": "바이너리", "LRAW": "긴 바이너리",
|
||||||
|
"STRG": "문자열", "SSTR": "짧은 문자열", "RSTR": "바이너리 문자열", "CLNT": "클라이언트", "LANG": "언어 키",
|
||||||
|
"LCHR": "긴 문자", "ACCP": "회계 기간(YYYYMM)", "PREC": "정밀도", "DF16_DEC": "10진 부동소수(16)", "DF34_DEC": "10진 부동소수(34)",
|
||||||
|
"D16D": "10진 부동소수(16)", "D34D": "10진 부동소수(34)", "D16R": "10진 부동소수(16,raw)", "D34R": "10진 부동소수(34,raw)",
|
||||||
|
"DATN": "날짜(네이티브)", "TIMN": "시간(네이티브)", "UTCL": "UTC 타임스탬프", "GEOM_EWKB": "지오메트리"},
|
||||||
|
"KEYFLAG": {"X": "키", "": ""},
|
||||||
|
"IS_CUSTOM": {"X": "고객 개발", "": "SAP 표준"},
|
||||||
|
"OPTIONAL": {"X": "선택", "": "필수"},
|
||||||
|
"REFERENCE": {"X": "참조", "": "값"},
|
||||||
|
"LOWERCASE": {"X": "허용", "": "대문자 변환"},
|
||||||
|
"VALEXI": {"X": "있음", "": "없음"},
|
||||||
|
"OBJTYPE": {"REPS": "프로그램/Include 소스", "CLSD": "클래스 정의", "METH": "메서드", "FUNC": "함수 모듈", "DYNP": "화면",
|
||||||
|
"TABD": "테이블 정의", "DTED": "데이터엘리먼트", "DOMD": "도메인", "CINC": "클래스 로컬 include", "CUAD": "GUI 상태",
|
||||||
|
"REPT": "프로그램 텍스트", "CPUB": "public 섹션", "CPRI": "private 섹션", "CPRO": "protected 섹션"},
|
||||||
|
"CONVEXIT": {"ALPHA": "앞자리 0 채움/제거", "": "없음"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 결과 셀 클릭 -> 다른 API 로 이어가기 (필드명 -> [메서드, 파라미터])
|
||||||
|
LINKS = {
|
||||||
|
"DEVCLASS": ["GET_OBJECT_LIST_BY_PACKAGE", "IV_PACKAGE"],
|
||||||
|
"PARENTCL": ["GET_OBJECT_LIST_BY_PACKAGE", "IV_PACKAGE"],
|
||||||
|
"FUNCNAME": ["GET_FUNCTION_DETAIL", "IV_FUNCTION"],
|
||||||
|
"TABNAME": ["GET_TABLE_FIELDS", "IV_TABLE"],
|
||||||
|
"CHECKTABLE": ["GET_TABLE_FIELDS", "IV_TABLE"],
|
||||||
|
"ENTITYTAB": ["GET_TABLE_FIELDS", "IV_TABLE"],
|
||||||
|
"ROLLNAME": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
|
||||||
|
"DOMNAME": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
|
||||||
|
"DBFIELD": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
|
||||||
|
"TRKORR": ["GET_CTS_OBJECT_LIST", "IV_TRKORR"],
|
||||||
|
"KORRNUM": ["GET_CTS_OBJECT_LIST", "IV_TRKORR"],
|
||||||
|
"TCODE": ["GET_TCODE_INFO", "IV_TCODE"],
|
||||||
|
"PGMNA": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
|
||||||
|
"MASTER": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
|
||||||
|
"INCL_NAME": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
|
||||||
|
"PROGRAM": ["GET_VERSION_LIST", "IV_OBJNAME"],
|
||||||
|
# OBJ_NAME 은 OBJECT 유형에 따라 다름 -> 프론트에서 OBJECT 값 보고 분기 (LINKS_BY_OBJECT)
|
||||||
|
}
|
||||||
|
LINKS_BY_OBJECT = {
|
||||||
|
"PROG": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
|
||||||
|
"REPS": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
|
||||||
|
"FUGR": ["GET_FUNCTION_LIST", "IV_FUNC_GROUP"],
|
||||||
|
"TABL": ["GET_TABLE_FIELDS", "IV_TABLE"],
|
||||||
|
"DTEL": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
|
||||||
|
"DOMA": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
|
||||||
|
"TRAN": ["GET_TCODE_INFO", "IV_TCODE"],
|
||||||
|
"CLAS": ["GET_VERSION_LIST", "IV_OBJNAME"],
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_ROWS = {"name": "IV_MAX_ROWS", "label": "최대 건수", "type": "number",
|
||||||
|
"desc": "생략/0 이면 전건 (안전 상한 50,000). 지정하면 그 건수까지만. 잘리면 TYPE=W + '(전체 M건)' + RETURN.TOTAL_ROWS=M (2026-09-04 상한 제거)"}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 메서드
|
||||||
|
METHODS = [
|
||||||
|
# ------------------------------------------------ 1
|
||||||
|
{
|
||||||
|
"name": "GET_PACKAGE_LIST", "title": "패키지 목록", "group": "1. 탐색 (어디에 뭐가 있나)",
|
||||||
|
"summary": "고객 개발(Z/Y) 패키지 목록을 가져온다. 패키지는 개발물을 담는 폴더 같은 것이라, 시스템 탐색의 시작점이다.",
|
||||||
|
"when": "처음 시스템을 볼 때, 또는 '영업/재무 관련 개발물이 어디 있지?' 를 설명 텍스트로 찾을 때.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_PATTERN", "label": "패키지명 패턴", "type": "string", "placeholder": "ZFI*",
|
||||||
|
"desc": "* 와일드카드 사용. 생략하면 Z* 와 Y* 전부"},
|
||||||
|
{"name": "IV_SEARCH_TEXT", "label": "설명 검색어", "type": "string", "placeholder": "API",
|
||||||
|
"desc": "패키지 설명(CTEXT)에 이 문자열이 포함된 것만. 대소문자 구분"},
|
||||||
|
MAX_ROWS,
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "전체 CBO 패키지 (기본, 전건)", "params": {}, "note": "이 시스템은 237개. 상한 없이 전건"},
|
||||||
|
{"label": "이름 패턴 ZFI*", "params": {"IV_PATTERN": "ZFI*"}},
|
||||||
|
{"label": "설명에 'API' 포함", "params": {"IV_SEARCH_TEXT": "API"}},
|
||||||
|
{"label": "상한 5건 → W 응답", "params": {"IV_MAX_ROWS": 5}, "note": "RETURN.TYPE=W, 메시지에 전체 건수, TOTAL_ROWS=237"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 3
|
||||||
|
{
|
||||||
|
"name": "GET_OBJECT_LIST_BY_PACKAGE", "title": "패키지 안의 오브젝트", "group": "1. 탐색 (어디에 뭐가 있나)",
|
||||||
|
"summary": "한 패키지에 들어 있는 모든 개발 오브젝트(프로그램, 클래스, 테이블, 함수그룹, 트랜잭션…)를 유형별로 나열한다.",
|
||||||
|
"when": "패키지를 골랐고 그 안에 뭐가 있는지 볼 때. OBJECT 컬럼 값으로 다음에 부를 API 가 정해진다.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_PACKAGE", "label": "패키지", "type": "string", "required": True, "placeholder": "ZFI01",
|
||||||
|
"desc": "GET_PACKAGE_LIST 의 DEVCLASS 값"},
|
||||||
|
{"name": "IV_OBJ_TYPE", "label": "오브젝트 유형", "type": "string", "placeholder": "PROG",
|
||||||
|
"desc": "PROG/FUGR/CLAS/TABL/DTEL/DOMA/TRAN/VIEW … 생략 시 전체"},
|
||||||
|
MAX_ROWS,
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "ZFI01 전체 (2,874건)", "params": {"IV_PACKAGE": "ZFI01", "IV_MAX_ROWS": 5000},
|
||||||
|
"note": "PROG 1,132 / DTEL 832 / TABL 428 / TRAN 201 / DOMA 128 ... 기본 상한(2000)이면 W"},
|
||||||
|
{"label": "ZFI01 테이블만 (428건)", "params": {"IV_PACKAGE": "ZFI01", "IV_OBJ_TYPE": "TABL"}},
|
||||||
|
{"label": "ZFI01 함수 그룹만 (14건)", "params": {"IV_PACKAGE": "ZFI01", "IV_OBJ_TYPE": "FUGR"}},
|
||||||
|
{"label": "ZFI01 프로그램 전건 (1,132건)", "params": {"IV_PACKAGE": "ZFI01", "IV_OBJ_TYPE": "PROG"},
|
||||||
|
"note": "Include(SUBC=I) 946건이 포함돼 GET_PROGRAM_LIST(177건)보다 많다"},
|
||||||
|
{"label": "ZFI01 프로그램 상한 500 → W", "params": {"IV_PACKAGE": "ZFI01", "IV_OBJ_TYPE": "PROG", "IV_MAX_ROWS": 500},
|
||||||
|
"note": "TYPE=W, 메시지 '(전체 1132건)', TOTAL_ROWS=1132"},
|
||||||
|
{"label": "필수값 누락 → E", "params": {}, "note": "IV_PACKAGE 없이 호출하면 오류 응답"},
|
||||||
|
],
|
||||||
|
"notes": ["2026-09-04 수정: 상한에서 잘려도 TYPE=S 였던 결함 → W + '(전체 M건)' + TOTAL_ROWS. 이어서 기본 상한 자체를 없앴다 (생략 시 전건).",
|
||||||
|
"정렬이 OBJECT 알파벳순이라 IV_MAX_ROWS 로 잘리면 뒤쪽 유형(TABL, TRAN …)이 통째로 빠진다. 전체가 필요하면 IV_MAX_ROWS 를 생략.",
|
||||||
|
"PROG 에는 Include 프로그램과 TADIR 항목만 남은 삭제 프로그램도 포함된다 (GET_PROGRAM_LIST 는 기본 1+M 만)."],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 2
|
||||||
|
{
|
||||||
|
"name": "GET_PROGRAM_LIST", "title": "프로그램 목록", "group": "2. 프로그램",
|
||||||
|
"summary": "패키지 또는 이름 패턴으로 ABAP 프로그램을 찾는다. 제목, 유형, 최종 변경자/일자를 함께 준다.",
|
||||||
|
"when": "특정 패키지의 프로그램을 훑거나, 'ZSD*' 처럼 이름 규칙으로 찾을 때. 기본은 실행 프로그램(1)과 화면 프로그램(M)만.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_PACKAGE", "label": "패키지", "type": "string", "placeholder": "ZFI01", "desc": "패키지로 필터"},
|
||||||
|
{"name": "IV_PATTERN", "label": "프로그램명 패턴", "type": "string", "placeholder": "ZFI1*", "desc": "* 와일드카드. 패키지와 패턴 둘 다 없으면 빈 결과"},
|
||||||
|
{"name": "IV_SUBC", "label": "프로그램 유형", "type": "string", "placeholder": "1",
|
||||||
|
"desc": "1=실행 프로그램, M=모듈 풀, I=Include, F=함수그룹, K=클래스 풀. 생략 시 1+M"},
|
||||||
|
MAX_ROWS,
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "패키지 ZFI01 (177건)", "params": {"IV_PACKAGE": "ZFI01"}},
|
||||||
|
{"label": "이름 패턴 Z* (10건)", "params": {"IV_PATTERN": "Z*", "IV_MAX_ROWS": 10}},
|
||||||
|
{"label": "ZFI01 의 Include 만 (946건)", "params": {"IV_PACKAGE": "ZFI01", "IV_SUBC": "I"}},
|
||||||
|
{"label": "필터 없음 → 안내 메시지", "params": {}, "note": "전건 조회 방지: S + 메시지, 결과 0건"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 4
|
||||||
|
{
|
||||||
|
"name": "GET_PROGRAM_SOURCE", "title": "프로그램 소스", "group": "2. 프로그램",
|
||||||
|
"summary": "프로그램의 ABAP 소스 전문을 가져온다. 옵션으로 Include 소스, 화면 흐름 로직, 연결된 트랜잭션 코드까지.",
|
||||||
|
"when": "코드를 읽거나 분석할 때. 모듈 풀(SAPM*) 은 실제 로직이 Include 에 있으므로 IV_WITH_INCLUDE=X 가 사실상 필수.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_PROGRAM", "label": "프로그램", "type": "string", "required": True, "placeholder": "SAPMZSFT0"},
|
||||||
|
{"name": "IV_WITH_INCLUDE", "label": "Include 포함", "type": "flag", "desc": "X 면 INCLUDE_LIST 에 각 Include 소스 포함"},
|
||||||
|
{"name": "IV_WITH_SCREEN", "label": "화면 포함", "type": "flag", "desc": "X 면 SCREEN_LIST 에 화면 흐름 로직 포함"},
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "모듈 풀 + Include + 화면 (SAPMZSFT0)", "params": {"IV_PROGRAM": "SAPMZSFT0", "IV_WITH_INCLUDE": "X", "IV_WITH_SCREEN": "X"},
|
||||||
|
"note": "근무 달력 유지보수. Include 1개, 화면 2개, 트랜잭션 ZSCAL"},
|
||||||
|
{"label": "리포트 소스만 (ZFI1000 계정별잔액조회)", "params": {"IV_PROGRAM": "ZFI1000"}},
|
||||||
|
{"label": "SAP 표준 (SAPMV45A, 화면 318개)", "params": {"IV_PROGRAM": "SAPMV45A", "IV_WITH_SCREEN": "X"}, "note": "응답 큼"},
|
||||||
|
{"label": "없는 프로그램 → E", "params": {"IV_PROGRAM": "ZZZ_NOT_EXIST"}},
|
||||||
|
],
|
||||||
|
"notes": ["Include 목록은 D010INC 기준이라 전이적으로 포함된 include 까지 전부 온다 (표준 SAPMV45A 는 1800개 이상). 클래스/Enhancement 시스템 include 는 서버가 걸러낸다 (2026-09-04 수정. 그 전엔 'CL_...==CU' 류가 섞였음).",
|
||||||
|
"소스에 제어문자(0x0C 등)가 있으면 원문 XML 이 규격 위반 → 이 서버가 파싱 전 제거함."],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 5
|
||||||
|
{
|
||||||
|
"name": "GET_FUNCTION_LIST", "title": "함수 모듈 목록", "group": "3. 함수 모듈",
|
||||||
|
"summary": "함수 그룹 또는 이름 패턴으로 함수 모듈(재사용 API 단위)을 찾는다.",
|
||||||
|
"when": "패키지 오브젝트 목록에서 FUGR 을 봤을 때 그 안의 함수를 볼 때, 또는 'BAPI_USER*' 처럼 이름으로 찾을 때.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_FUNC_GROUP", "label": "함수 그룹", "type": "string", "placeholder": "ZFG_FI02", "desc": "GET_OBJECT_LIST_BY_PACKAGE 의 FUGR 이름"},
|
||||||
|
{"name": "IV_PATTERN", "label": "함수명 패턴", "type": "string", "placeholder": "BAPI_USER_GET*", "desc": "* 와일드카드. 그룹과 패턴 둘 다 없으면 빈 결과"},
|
||||||
|
MAX_ROWS,
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "함수 그룹 ZFG_FI02 (14건, 전표/BP 처리)", "params": {"IV_FUNC_GROUP": "ZFG_FI02"}},
|
||||||
|
{"label": "함수 그룹 ZLEASE01", "params": {"IV_FUNC_GROUP": "ZLEASE01"}},
|
||||||
|
{"label": "이름 패턴 ZFIBP_* (고객 함수)", "params": {"IV_PATTERN": "ZFIBP_*"}},
|
||||||
|
{"label": "이름 패턴 BAPI_USER_GET*", "params": {"IV_PATTERN": "BAPI_USER_GET*"}},
|
||||||
|
{"label": "필터 없음 → 안내 메시지", "params": {}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 6
|
||||||
|
{
|
||||||
|
"name": "GET_FUNCTION_DETAIL", "title": "함수 모듈 상세", "group": "3. 함수 모듈",
|
||||||
|
"summary": "함수 하나의 본문 소스(FUNC_SOURCE), 인터페이스(입력/출력/변경/테이블 파라미터, 예외), 함수 그룹의 TOP/FORM include 소스를 준다.",
|
||||||
|
"when": "함수를 호출하거나 그 동작을 이해해야 할 때. 파라미터 표만 봐도 시그니처를 알 수 있다.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_FUNCTION", "label": "함수 모듈", "type": "string", "required": True, "placeholder": "BAPI_USER_GET_DETAIL"},
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "SAP 표준 BAPI_USER_GET_DETAIL", "params": {"IV_FUNCTION": "BAPI_USER_GET_DETAIL"}},
|
||||||
|
{"label": "고객 함수 ZFI_CHECK_STCD2 (사업자번호 체크)", "params": {"IV_FUNCTION": "ZFI_CHECK_STCD2"}, "note": "소스 출처 INCLUDE LZFG_FI02U01"},
|
||||||
|
{"label": "없는 함수 → E", "params": {"IV_FUNCTION": "Z_NOT_EXIST_FM"}},
|
||||||
|
],
|
||||||
|
"notes": ["FUNC_SOURCE 는 함수 Include(L<그룹>U##)를 통째로 읽은 것이라 FUNCTION ~ ENDFUNCTION 과 긴 줄(72자 초과)이 그대로 온다. RETURN.MESSAGE 에 출처가 찍힌다.",
|
||||||
|
"2026-09-04 수정 전에는 항상 빈값이었다 (RPY_FUNCTIONMODULE_READ 의 SOURCE 테이블은 이 시스템에서 채워지지 않고 NEW_SOURCE 파라미터도 없음).",
|
||||||
|
"파라미터 목록의 행 태그는 <item> 이 아니라 <RSIMP>/<RSEXP>/<RSCHA>/<RSTBL>/<RSEXC> 다. 이 서버의 파서는 이를 목록으로 처리한다 (직접 파싱할 때 주의)."],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 7
|
||||||
|
{
|
||||||
|
"name": "GET_TABLE_LIST", "title": "테이블 목록", "group": "4. 테이블 / 타입",
|
||||||
|
"summary": "이름 패턴 또는 설명 검색어로 DB 테이블/구조를 찾는다.",
|
||||||
|
"when": "'고객' 관련 테이블이 뭐가 있는지 설명으로 찾거나, Z 테이블 전체를 훑을 때.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_PATTERN", "label": "테이블명 패턴", "type": "string", "placeholder": "ZFIT*"},
|
||||||
|
{"name": "IV_SEARCH_TEXT", "label": "설명 검색어", "type": "string", "placeholder": "고객", "desc": "DDTEXT 부분 일치. 로그인 언어(한국어)/영어/독일어 텍스트 대상"},
|
||||||
|
{"name": "IV_CUSTOM_ONLY", "label": "고객 개발만", "type": "flag", "desc": "X 면 Z*/Y* 테이블만"},
|
||||||
|
MAX_ROWS,
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "Z 테이블 50건", "params": {"IV_PATTERN": "Z*", "IV_CUSTOM_ONLY": "X", "IV_MAX_ROWS": 50}},
|
||||||
|
{"label": "설명에 '고객'", "params": {"IV_SEARCH_TEXT": "고객", "IV_MAX_ROWS": 20}},
|
||||||
|
{"label": "이름 ZFIT* (737건)", "params": {"IV_PATTERN": "ZFIT*"}},
|
||||||
|
{"label": "필터 없음 → 안내 메시지", "params": {}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 8
|
||||||
|
{
|
||||||
|
"name": "GET_TABLE_FIELDS", "title": "테이블 필드(컬럼)", "group": "4. 테이블 / 타입",
|
||||||
|
"summary": "테이블의 컬럼 목록: 키 여부, 타입/길이, 데이터엘리먼트, 도메인, 체크 테이블, 한국어 설명·라벨.",
|
||||||
|
"when": "테이블 구조를 알아야 할 때(스키마 문서 대용). ROLLNAME 을 클릭하면 타입 상세로 이어진다.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_TABLE", "label": "테이블", "type": "string", "required": True, "placeholder": "ZFIT0000"},
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "고객 테이블 ZFIT0000 (권한별 유저ID 관리)", "params": {"IV_TABLE": "ZFIT0000"}},
|
||||||
|
{"label": "SAP 표준 MARA (자재 마스터, 304컬럼)", "params": {"IV_TABLE": "MARA"}},
|
||||||
|
{"label": "T000 (클라이언트)", "params": {"IV_TABLE": "T000"}},
|
||||||
|
{"label": "없는 테이블 → E", "params": {"IV_TABLE": "ZZZ_NOPE"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 9
|
||||||
|
{
|
||||||
|
"name": "GET_TYPE_INFO", "title": "타입(데이터엘리먼트/도메인) 정보", "group": "4. 테이블 / 타입",
|
||||||
|
"summary": "데이터엘리먼트 또는 도메인 이름을 주면 타입·길이·라벨·변환 루틴·허용값(고정값) 목록을 준다.",
|
||||||
|
"when": "필드 값이 무슨 뜻인지, 어떤 값이 들어올 수 있는지 알고 싶을 때. 예: MTART 의 허용값은 T134 테이블, XFELD 는 X/공백.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_TYPE_NAME", "label": "데이터엘리먼트 또는 도메인", "type": "string", "required": True, "placeholder": "BUKRS",
|
||||||
|
"desc": "먼저 데이터엘리먼트로 찾고, 없으면 도메인으로 간주"},
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "BUKRS (회사 코드)", "params": {"IV_TYPE_NAME": "BUKRS"}},
|
||||||
|
{"label": "XFELD (체크박스, 고정값 2개)", "params": {"IV_TYPE_NAME": "XFELD"}},
|
||||||
|
{"label": "MTART (자재 유형, 값 테이블 T134)", "params": {"IV_TYPE_NAME": "MTART"}},
|
||||||
|
{"label": "고객 타입 ZE_AUTHCODE (권한관리코드, 고정값 7개)", "params": {"IV_TYPE_NAME": "ZE_AUTHCODE"}},
|
||||||
|
{"label": "없는 타입 → E", "params": {"IV_TYPE_NAME": "ZZZ_NOPE"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 10
|
||||||
|
{
|
||||||
|
"name": "GET_FIELD_LIST_BY_TEXT", "title": "업무 용어로 필드 역검색", "group": "4. 테이블 / 타입",
|
||||||
|
"summary": "'회사코드' 같은 업무 용어로 데이터엘리먼트를 찾고, 그 엘리먼트를 쓰는 테이블·필드까지 같이 준다.",
|
||||||
|
"when": "테이블/필드 이름을 전혀 모를 때. 자연어 → 기술 이름 매핑의 출발점.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_SEARCH_TEXT", "label": "검색어", "type": "string", "required": True, "placeholder": "회사코드"},
|
||||||
|
{"name": "IV_CUSTOM_ONLY", "label": "고객 개발만", "type": "flag", "desc": "X 면 Z/Y 엘리먼트와 Z/Y 테이블만"},
|
||||||
|
MAX_ROWS,
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "'회사코드' 고객 개발만", "params": {"IV_SEARCH_TEXT": "회사코드", "IV_CUSTOM_ONLY": "X", "IV_MAX_ROWS": 10}},
|
||||||
|
{"label": "'레코드생성일' 전체", "params": {"IV_SEARCH_TEXT": "레코드생성일", "IV_MAX_ROWS": 10}},
|
||||||
|
{"label": "'자재' 전체 20건", "params": {"IV_SEARCH_TEXT": "자재", "IV_MAX_ROWS": 20}},
|
||||||
|
{"label": "'회사코드' 전체 10건 (BUKRS 계열은 사용 테이블 수천 건)", "params": {"IV_SEARCH_TEXT": "회사코드", "IV_MAX_ROWS": 10},
|
||||||
|
"note": "사용 테이블은 제한 없이 전부 온다 (2026-09-04 20건 상한 제거)"},
|
||||||
|
{"label": "일치 없음 → S, 0건", "params": {"IV_SEARCH_TEXT": "zzqqxx없는용어"}, "note": "2026-09-04 수정 전에는 응답 본문이 비어 있었음"},
|
||||||
|
{"label": "필수값 누락 → E", "params": {}},
|
||||||
|
],
|
||||||
|
"notes": ["USED_TABLES 는 엘리먼트당 제한 없이 전부 온다 (2026-09-04 20건 고정 상한 제거). 사용 테이블 총량이 안전 상한 50,000 을 넘으면 조회를 멈추고 TYPE=W + 안내 메시지 (그 뒤 후보의 USED_TABLES 는 빈값). 검색어를 좁히거나 IV_CUSTOM_ONLY=X, IV_MAX_ROWS 로 줄일 것.",
|
||||||
|
"2026-09-04 수정: 후보 0건이면 CHECK 로 빠져 응답 본문이 0바이트였던 결함. 지금은 빈 RESULT 를 정상 XML 로 돌려준다."],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 11
|
||||||
|
{
|
||||||
|
"name": "GET_CTS_LIST", "title": "전송 요청(CTS) 목록", "group": "5. 변경 이력 (CTS / 버전)",
|
||||||
|
"summary": "기간 내 릴리즈된 전송 요청(Transport Request) 목록. 전송 요청은 '변경 사항 묶음' 으로, 개발→운영 이관 단위다.",
|
||||||
|
"when": "최근 누가 무엇을 바꿨는지 볼 때. 결과의 TRKORR 로 GET_CTS_OBJECT_LIST 를 부르면 바뀐 오브젝트가 나온다.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_DATE_FROM", "label": "시작일", "type": "date", "required": True, "placeholder": "2025-01-01", "desc": "반드시 YYYY-MM-DD (YYYYMMDD 는 인식 못함)"},
|
||||||
|
{"name": "IV_DATE_TO", "label": "종료일", "type": "date", "required": True, "placeholder": "2026-12-31"},
|
||||||
|
{"name": "IV_USER", "label": "소유자", "type": "string", "placeholder": "PWC322063", "desc": "요청 소유자 사용자 ID"},
|
||||||
|
{"name": "IV_FUNCTION", "label": "요청 유형", "type": "string", "placeholder": "K", "desc": "K=워크벤치, W=커스터마이징. 생략 시 K+W"},
|
||||||
|
MAX_ROWS,
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "최근 1년, 20건", "params": {"IV_DATE_FROM": "2025-09-03", "IV_DATE_TO": "2026-09-03", "IV_MAX_ROWS": 20}},
|
||||||
|
{"label": "2020~ 워크벤치만 5건", "params": {"IV_DATE_FROM": "2020-01-01", "IV_DATE_TO": "2026-12-31", "IV_FUNCTION": "K", "IV_MAX_ROWS": 5}},
|
||||||
|
{"label": "잘못된 날짜 형식 → E", "params": {"IV_DATE_FROM": "20200101", "IV_DATE_TO": "20261231"}, "note": "YYYYMMDD 는 빈 값으로 취급됨"},
|
||||||
|
],
|
||||||
|
"notes": ["릴리즈 완료(TRSTATUS=R) 된 상위 요청만. 하위 태스크는 제외."],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 12
|
||||||
|
{
|
||||||
|
"name": "GET_CTS_OBJECT_LIST", "title": "전송 요청의 변경 오브젝트", "group": "5. 변경 이력 (CTS / 버전)",
|
||||||
|
"summary": "전송 요청(과 그 하위 태스크)에 담긴 오브젝트 목록.",
|
||||||
|
"when": "특정 배포에 무엇이 포함됐는지 확인할 때.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_TRKORR", "label": "전송 요청 번호", "type": "string", "required": True, "placeholder": "EEDK9A1KSP"},
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "작은 요청 EEDK9A1KSP (3건)", "params": {"IV_TRKORR": "EEDK9A1KSP"}},
|
||||||
|
{"label": "큰 요청 S4HK961147 (1000건+)", "params": {"IV_TRKORR": "S4HK961147"}},
|
||||||
|
{"label": "필수값 누락 → E", "params": {}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 13
|
||||||
|
{
|
||||||
|
"name": "GET_VERSION_LIST", "title": "오브젝트 버전 이력", "group": "5. 변경 이력 (CTS / 버전)",
|
||||||
|
"summary": "오브젝트의 저장된 버전 목록(버전 번호, 전송 요청, 작성자, 일시). 전송 시점마다 버전이 남는다.",
|
||||||
|
"when": "언제 누가 바꿨는지 추적할 때. 버전이 0건이면 한 번도 전송된 적 없는 오브젝트.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_OBJNAME", "label": "오브젝트 이름", "type": "string", "required": True, "placeholder": "ZCL_FI_LS_ALV_GRID"},
|
||||||
|
{"name": "IV_OBJTYPE", "label": "버전 오브젝트 유형", "type": "string", "required": True, "placeholder": "CLSD",
|
||||||
|
"desc": "REPS=프로그램/Include, CLSD=클래스 정의, METH=메서드, FUNC=함수, TABD=테이블 정의. 함수그룹 TOP 은 L<그룹>TOP + REPS"},
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "클래스 ZCL_FI_LS_ALV_GRID (6버전)", "params": {"IV_OBJNAME": "ZCL_FI_LS_ALV_GRID", "IV_OBJTYPE": "CLSD"}},
|
||||||
|
{"label": "함수그룹 TOP LZLEASE01TOP (6버전)", "params": {"IV_OBJNAME": "LZLEASE01TOP", "IV_OBJTYPE": "REPS"}},
|
||||||
|
{"label": "테이블 정의 ZRPFXT0010 (TABD, 8버전)", "params": {"IV_OBJNAME": "ZRPFXT0010", "IV_OBJTYPE": "TABD"},
|
||||||
|
"note": "테이블도 버전 이력은 나온다. 단 GET_VERSION_SOURCE 는 TABD 를 지원하지 않아 정의 diff 는 불가"},
|
||||||
|
{"label": "버전 이력 없는 프로그램 ZFI1000 (0건)", "params": {"IV_OBJNAME": "ZFI1000", "IV_OBJTYPE": "REPS"}, "note": "ZFI01 은 이관 패키지라 버전 이력이 없다"},
|
||||||
|
{"label": "필수값 누락 → E", "params": {}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 14
|
||||||
|
{
|
||||||
|
"name": "GET_VERSION_SOURCE", "title": "특정 버전의 소스", "group": "5. 변경 이력 (CTS / 버전)",
|
||||||
|
"summary": "버전 번호를 지정해 그 시점의 소스를 가져온다. 두 버전을 받아 diff 하면 변경 내용을 알 수 있다.",
|
||||||
|
"when": "변경 전/후 비교. GET_VERSION_LIST 로 버전 번호를 고른 뒤 두 버전을 받아 diff 한다.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_OBJNAME", "label": "오브젝트 이름", "type": "string", "required": True, "placeholder": "LZLEASE01TOP"},
|
||||||
|
{"name": "IV_OBJTYPE", "label": "버전 오브젝트 유형", "type": "string", "required": True, "placeholder": "REPS"},
|
||||||
|
{"name": "IV_VERSNO", "label": "버전 번호", "type": "string", "placeholder": "00000", "desc": "생략/00000 = 활성(현재) 소스. 그 외 = 과거 버전"},
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "활성 버전 LZLEASE01TOP", "params": {"IV_OBJNAME": "LZLEASE01TOP", "IV_OBJTYPE": "REPS"}},
|
||||||
|
{"label": "활성 버전 ZFI1000", "params": {"IV_OBJNAME": "ZFI1000", "IV_OBJTYPE": "REPS", "IV_VERSNO": "00000"}},
|
||||||
|
{"label": "과거 버전 LZLEASE01TOP 00001", "params": {"IV_OBJNAME": "LZLEASE01TOP", "IV_OBJTYPE": "REPS", "IV_VERSNO": "00001"}},
|
||||||
|
{"label": "없는 버전 → E", "params": {"IV_OBJNAME": "LZLEASE01TOP", "IV_OBJTYPE": "REPS", "IV_VERSNO": "99999"}},
|
||||||
|
{"label": "TABD 과거 버전 → E (테이블 정의는 미지원)", "params": {"IV_OBJNAME": "ZRPFXT0010", "IV_OBJTYPE": "TABD", "IV_VERSNO": "00001"},
|
||||||
|
"note": "GET_VERSION_LIST 에는 8버전이 있지만 소스 조회는 REPS 만 지원. 활성(00000)도 '소스를 읽을 수 없습니다' E"},
|
||||||
|
{"label": "CLSD 과거 버전 → E (REPS 만 지원)", "params": {"IV_OBJNAME": "ZCL_ZAA_AGENT_API", "IV_OBJTYPE": "CLSD", "IV_VERSNO": "00001"}},
|
||||||
|
],
|
||||||
|
"notes": ["과거 버전은 표준 함수 SVRS_GET_REPS_FROM_OBJECT 로 버전 DB 에서 읽는다 (2026-09-04 수정. 그 전엔 없는 함수 SVRS2_GET_VERSION_REPOS_46 을 불러 HTTP 500).",
|
||||||
|
"과거 버전은 IV_OBJTYPE=REPS(프로그램/Include) 만 지원. 클래스는 메서드 Include 이름(ZCL_X=====CM001 형태)으로 조회.",
|
||||||
|
"활성 버전은 READ REPORT 로 읽으므로 CLSD(클래스) 타입은 '소스를 읽을 수 없습니다' 가 정상.",
|
||||||
|
"함수 그룹 TOP include 는 내용이 안 바뀌어도 전송 때마다 버전이 생겨 여러 버전이 같은 소스일 수 있다."],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 15
|
||||||
|
{
|
||||||
|
"name": "GET_WHERE_USED_LIST", "title": "사용처 역추적 (Where-Used)", "group": "6. 관계 추적",
|
||||||
|
"summary": "테이블/데이터엘리먼트/함수/프로그램을 어떤 프로그램들이 사용하는지 찾는다.",
|
||||||
|
"when": "영향도 분석: '이 테이블 바꾸면 어디가 깨지나'. 표준 오브젝트는 결과가 많으니 IV_MAX_ROWS 를 작게.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_OBJ_TYPE", "label": "오브젝트 유형", "type": "string", "required": True, "placeholder": "TABL",
|
||||||
|
"desc": "검증됨: TABL(테이블/구조), DE(데이터엘리먼트), FUNC(함수), PROG(프로그램). 별칭 DS/STRU, DTEL/DD 도 동작. TB/TA 는 오류(rc=7)"},
|
||||||
|
{"name": "IV_OBJ_NAME", "label": "오브젝트 이름", "type": "string", "required": True, "placeholder": "ZFIT0000"},
|
||||||
|
MAX_ROWS,
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "고객 테이블 ZFIT0000 사용처 (7건)", "params": {"IV_OBJ_TYPE": "TABL", "IV_OBJ_NAME": "ZFIT0000"}},
|
||||||
|
{"label": "테이블 T000 사용처 (5건)", "params": {"IV_OBJ_TYPE": "TABL", "IV_OBJ_NAME": "T000", "IV_MAX_ROWS": 5}},
|
||||||
|
{"label": "데이터엘리먼트 BUKRS (5건)", "params": {"IV_OBJ_TYPE": "DE", "IV_OBJ_NAME": "BUKRS", "IV_MAX_ROWS": 5}},
|
||||||
|
{"label": "함수 ZFI_CHECK_STCD2 (1건)", "params": {"IV_OBJ_TYPE": "FUNC", "IV_OBJ_NAME": "ZFI_CHECK_STCD2"}},
|
||||||
|
{"label": "잘못된 유형 TB → E (rc=7)", "params": {"IV_OBJ_TYPE": "TB", "IV_OBJ_NAME": "T000"}},
|
||||||
|
],
|
||||||
|
"notes": ["표준 오브젝트(MARA, BUKRS 등)는 사용처가 수천 건이라 느릴 수 있음. IV_MAX_ROWS 는 잘라주기만 하고 조회 자체를 줄이진 않음."],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 16
|
||||||
|
{
|
||||||
|
"name": "GET_TCODE_INFO", "title": "트랜잭션 코드 ↔ 프로그램", "group": "6. 관계 추적",
|
||||||
|
"summary": "트랜잭션 코드(사용자가 SAP 에서 치는 명령어)로 실행 프로그램을 찾거나, 프로그램으로 트랜잭션 코드를 찾는다.",
|
||||||
|
"when": "사용자가 'VA01 화면' 이라고 말할 때 그 뒤의 프로그램을 찾거나, 프로그램이 어떤 메뉴로 실행되는지 볼 때.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_TCODE", "label": "트랜잭션 코드", "type": "string", "placeholder": "ZFI1000", "desc": "둘 중 하나는 필수"},
|
||||||
|
{"name": "IV_PROGRAM", "label": "프로그램", "type": "string", "placeholder": "SAPMV45A"},
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "T코드 ZFI1000 → 프로그램", "params": {"IV_TCODE": "ZFI1000"}},
|
||||||
|
{"label": "T코드 ZSCAL → 모듈 풀 SAPMZSFT0", "params": {"IV_TCODE": "ZSCAL"}},
|
||||||
|
{"label": "프로그램 SAPMV45A → T코드 25개", "params": {"IV_PROGRAM": "SAPMV45A"}},
|
||||||
|
{"label": "없는 T코드 → E", "params": {"IV_TCODE": "ZZZ_NOPE"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ------------------------------------------------ 17
|
||||||
|
{
|
||||||
|
"name": "GET_OBJECT_TYPE", "title": "오브젝트 유형 판별", "group": "6. 관계 추적",
|
||||||
|
"summary": "이름 하나를 주면 그것이 실행 프로그램인지 Include 인지 테이블·구조·뷰·타입·클래스·함수·T코드인지 판별한다. Include 면 그것을 포함하는 메인 프로그램 목록(D010INC)까지 준다.",
|
||||||
|
"when": "사용처(where-used)나 CTS 오브젝트 목록에 섞여 나온 이름을 실행 단위(루트 프로그램)로 정리할 때. 이름 규칙에 의존하지 않고 Include → 메인 프로그램을 찾는 유일한 방법.",
|
||||||
|
"params": [
|
||||||
|
{"name": "IV_OBJ_NAME", "label": "오브젝트 이름", "type": "string", "required": True, "placeholder": "ZFIMLS0010F01",
|
||||||
|
"desc": "프로그램/Include/테이블/구조/뷰/테이블타입/데이터엘리먼트/도메인/클래스/인터페이스/함수그룹/함수/T코드 이름"},
|
||||||
|
],
|
||||||
|
"samples": [
|
||||||
|
{"label": "Include → 메인 프로그램 (ZFIMLS0010F01)", "params": {"IV_OBJ_NAME": "ZFIMLS0010F01"}, "note": "KIND=INCLUDE, MASTERS 에 ZFIMLS0010"},
|
||||||
|
{"label": "함수그룹 Include (LZLEASE01TOP)", "params": {"IV_OBJ_NAME": "LZLEASE01TOP"}, "note": "MASTERS 에 SAPLZLEASE01"},
|
||||||
|
{"label": "실행 프로그램 + T코드 동명 (ZFI1000)", "params": {"IV_OBJ_NAME": "ZFI1000"}, "note": "RESULT 2행: PROG/REPORT 와 TRAN/TCODE"},
|
||||||
|
{"label": "테이블 (ZFIT0000)", "params": {"IV_OBJ_NAME": "ZFIT0000"}},
|
||||||
|
{"label": "클래스 풀 이름 (ZCL_FI_LS_ALV_GRID=...CP 형태는 TRDIR 로 CLASS_POOL)", "params": {"IV_OBJ_NAME": "ZCL_FI_LS_ALV_GRID"}, "note": "클래스 이름은 CLASS"},
|
||||||
|
{"label": "없는 이름 → E", "params": {"IV_OBJ_NAME": "ZZZ_NOPE"}},
|
||||||
|
],
|
||||||
|
"notes": ["2026-09-07 추가 (cts-diff-tester 의 루트 프로그램 정규화용). 같은 이름이 여러 유형에 있으면 RESULT 에 전부 나오고 KIND 는 첫 행(프로그램 우선).",
|
||||||
|
"MASTERS 는 D010INC 기준이라 활성화된 적 없는 Include 는 비어 있을 수 있다 (그때 RETURN.TYPE=W)."],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
CATALOG = {
|
||||||
|
"methods": METHODS,
|
||||||
|
"fields": {k: {"label": v[0], "desc": v[1]} for k, v in FIELDS.items()},
|
||||||
|
"value_maps": VALUE_MAPS,
|
||||||
|
"links": LINKS,
|
||||||
|
"links_by_object": LINKS_BY_OBJECT,
|
||||||
|
"code_fields": CODE_FIELDS,
|
||||||
|
"list_fields": sorted(LIST_FIELDS),
|
||||||
|
"conventions": [
|
||||||
|
"요청: POST /sap/bc/ZAA_ICF/{METHOD}, Content-Type: application/json, 바디 {\"INPUT\": {\"IV_...\": ...}}",
|
||||||
|
"응답: asXML. <DATA> 아래 <RETURN>(TYPE/MESSAGE) + 결과. 반복 행은 <item>.",
|
||||||
|
"응답 XML 선언이 encoding=\"utf-16\" 이지만 실제는 UTF-8 → 이 서버가 선언을 떼고 파싱.",
|
||||||
|
"날짜(DATS) 파라미터는 YYYY-MM-DD.",
|
||||||
|
"플래그(CHAR1) 파라미터는 'X' 가 참.",
|
||||||
|
"목록 API 의 IV_MAX_ROWS 는 생략하면 전건(안전 상한 50,000). 지정해서 잘리면 RETURN.TYPE=W, MESSAGE 에 전체 건수, RETURN.TOTAL_ROWS.",
|
||||||
|
"이름류 파라미터는 서버가 대문자로 바꾸지만, 검색어(IV_SEARCH_TEXT)는 원문 그대로 비교.",
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ZAA_ICF (ZCL_ZAA_AGENT_API) MCP 서버.
|
||||||
|
|
||||||
|
catalog.py 의 16개 메서드를 MCP 툴로 노출한다 (툴 이름 = 메서드명 소문자, 파라미터 = IV_ 접두사 뗀 소문자).
|
||||||
|
SAP 호출은 sap_client.sap_call (../abap-api-tester 의 웹 테스터와 같은 코드).
|
||||||
|
|
||||||
|
실행: python mcp_server.py (stdio — Claude Code / Claude Desktop 용)
|
||||||
|
python mcp_server.py --http 8766 (streamable-http, http://127.0.0.1:8766/mcp)
|
||||||
|
접속정보: .env 또는 환경변수 SAP_URL / SAP_USER / SAP_PASS (sap_client 가 읽는다)
|
||||||
|
|
||||||
|
환경변수 (선택):
|
||||||
|
ABAP_MCP_DEFAULT_MAX_ROWS 목록 툴에서 max_rows 생략 시 상한 (기본 200). LLM 컨텍스트 보호용
|
||||||
|
ABAP_MCP_MAX_OUTPUT_CHARS 툴 응답 JSON 최대 문자 수 (기본 120000). 넘치면 소스 → 행 순으로 잘라내고 알린다
|
||||||
|
ABAP_MCP_TIMEOUT SAP 호출 타임아웃 초 (기본 180)
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
from pydantic import Field
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
from mcp.server.mcpserver.exceptions import ToolError
|
||||||
|
|
||||||
|
from catalog import CATALOG, CODE_FIELDS, FIELDS, LIST_FIELDS, METHODS, VALUE_MAPS
|
||||||
|
import sap_client
|
||||||
|
from sap_client import CONFIG, sap_call
|
||||||
|
|
||||||
|
DEFAULT_MAX_ROWS = int(os.environ.get("ABAP_MCP_DEFAULT_MAX_ROWS", "200"))
|
||||||
|
MAX_OUTPUT_CHARS = int(os.environ.get("ABAP_MCP_MAX_OUTPUT_CHARS", "120000"))
|
||||||
|
TIMEOUT = int(os.environ.get("ABAP_MCP_TIMEOUT", "180"))
|
||||||
|
|
||||||
|
# 소스를 돌려주는 메서드에는 줄 범위 파라미터를 붙여 LLM 이 페이지 단위로 읽게 한다
|
||||||
|
SOURCE_METHODS = {"GET_PROGRAM_SOURCE", "GET_VERSION_SOURCE", "GET_FUNCTION_DETAIL"}
|
||||||
|
|
||||||
|
INSTRUCTIONS = """SAP ABAP 시스템(ZCL_ZAA_AGENT_API, 읽기 전용)을 탐색하는 툴 모음. 모든 툴은 조회만 하며 아무것도 바꾸지 않는다.
|
||||||
|
|
||||||
|
탐색 흐름
|
||||||
|
- 어디에 뭐가 있나: get_package_list → get_object_list_by_package(OBJECT 값이 다음 툴을 정한다: PROG→get_program_source, FUGR→get_function_list, TABL→get_table_fields, DTEL/DOMA→get_type_info, TRAN→get_tcode_info)
|
||||||
|
- 사용자가 'VA01 화면' 처럼 T코드로 말하면 get_tcode_info 로 프로그램을 찾은 뒤 get_program_source(with_include=true)
|
||||||
|
- 업무 용어('회사코드')만 알 때: get_field_list_by_text → 데이터엘리먼트/테이블 → get_table_fields / get_type_info
|
||||||
|
- 영향도 분석: get_where_used_list. 변경 이력: get_cts_list → get_cts_object_list, get_version_list → get_version_source 두 버전 diff
|
||||||
|
|
||||||
|
응답 읽는 법
|
||||||
|
- 모든 응답은 {"return": {"type","message","total_rows"}, ...결과}. type S=성공, W=경고(대개 결과가 상한에서 잘림), E=입력 오류/미존재, X=통신·파싱 오류.
|
||||||
|
- 목록 툴은 max_rows 를 생략하면 %d 건까지만 가져온다. 잘리면 type=W 와 total_rows 로 전체 건수를 알려주니, 더 필요하면 max_rows 를 올리거나 0(전건, 안전 상한 50,000)으로 부른다. 페이지(offset) 개념은 없다. 정렬이 유형/이름순이라 잘리면 뒤쪽 유형이 통째로 빠질 수 있다.
|
||||||
|
- 응답이 너무 크면 이 서버가 소스 → 목록 행 순으로 잘라내고 "_truncated" 에 알린다. 소스는 line_from/line_to 로 나눠 읽는다.
|
||||||
|
- 이름류 입력은 서버가 대문자로 바꾸지만 검색어(search_text)는 원문 그대로 부분 일치·대소문자 구분. 날짜는 YYYY-MM-DD.
|
||||||
|
- 코드값 뜻이 궁금하면 explain_fields 로 필드 사전을 본다 (예: SUBC, OBJECT, TRFUNCTION).
|
||||||
|
""" % DEFAULT_MAX_ROWS
|
||||||
|
|
||||||
|
mcp = MCPServer(
|
||||||
|
"abap-sap",
|
||||||
|
title="SAP ABAP 탐색 (ZAA_ICF)",
|
||||||
|
instructions=INSTRUCTIONS,
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 카탈로그 → 툴 시그니처
|
||||||
|
def _arg_name(p: dict) -> str:
|
||||||
|
n = p["name"]
|
||||||
|
return (n[3:] if n.startswith("IV_") else n).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _py_type(p: dict):
|
||||||
|
return {"number": int, "flag": bool}.get(p["type"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def _param_desc(p: dict) -> str:
|
||||||
|
d = p.get("label", "")
|
||||||
|
if p.get("desc"):
|
||||||
|
d += ". " + p["desc"]
|
||||||
|
if p.get("placeholder") and p["type"] != "flag":
|
||||||
|
d += f" (예: {p['placeholder']})"
|
||||||
|
if p["type"] == "date":
|
||||||
|
d += ". 형식 YYYY-MM-DD"
|
||||||
|
if p["name"] == "IV_MAX_ROWS":
|
||||||
|
d = f"최대 건수. 생략 시 {DEFAULT_MAX_ROWS}, 0 이면 전건(안전 상한 50,000). 잘리면 return.type=W + total_rows"
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_description(m: dict) -> str:
|
||||||
|
lines = [f"[{m['title']}] {m['summary']}", f"언제: {m['when']}"]
|
||||||
|
for n in m.get("notes", []):
|
||||||
|
lines.append(f"주의: {n}")
|
||||||
|
ex = [s for s in m.get("samples", []) if s["params"] and "→ E" not in s["label"]][:3]
|
||||||
|
if ex:
|
||||||
|
lines.append("예: " + " / ".join(
|
||||||
|
f"{s['label']}: {json.dumps({_arg_name({'name': k}): v for k, v in s['params'].items()}, ensure_ascii=False)}"
|
||||||
|
for s in ex))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_signature(m: dict) -> inspect.Signature:
|
||||||
|
params = []
|
||||||
|
for p in m["params"]:
|
||||||
|
name, typ = _arg_name(p), _py_type(p)
|
||||||
|
if p.get("required"):
|
||||||
|
ann, default = Annotated[typ, Field(description=_param_desc(p))], inspect.Parameter.empty
|
||||||
|
elif typ is bool:
|
||||||
|
ann, default = Annotated[bool, Field(description=_param_desc(p))], False
|
||||||
|
else:
|
||||||
|
ann, default = Annotated[typ | None, Field(description=_param_desc(p))], None
|
||||||
|
params.append(inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY, default=default, annotation=ann))
|
||||||
|
if m["name"] in SOURCE_METHODS:
|
||||||
|
params.append(inspect.Parameter("line_from", inspect.Parameter.KEYWORD_ONLY, default=None,
|
||||||
|
annotation=Annotated[int | None, Field(description="소스 시작 줄(1부터). 긴 소스를 나눠 읽을 때. 생략 시 처음부터")]))
|
||||||
|
params.append(inspect.Parameter("line_to", inspect.Parameter.KEYWORD_ONLY, default=None,
|
||||||
|
annotation=Annotated[int | None, Field(description="소스 끝 줄(포함). 생략 시 끝까지")]))
|
||||||
|
# 필수 파라미터가 앞에 오도록 (KEYWORD_ONLY 라 순서 제약은 없지만 스키마 가독성)
|
||||||
|
params.sort(key=lambda x: x.default is not inspect.Parameter.empty)
|
||||||
|
return inspect.Signature(params, return_annotation=dict[str, Any])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 호출 + 응답 다듬기
|
||||||
|
def _to_sap_params(m: dict, kwargs: dict) -> dict:
|
||||||
|
out = {}
|
||||||
|
for p in m["params"]:
|
||||||
|
v = kwargs.get(_arg_name(p))
|
||||||
|
if p["type"] == "flag":
|
||||||
|
if v:
|
||||||
|
out[p["name"]] = "X"
|
||||||
|
elif p["name"] == "IV_MAX_ROWS":
|
||||||
|
if v is None:
|
||||||
|
out[p["name"]] = DEFAULT_MAX_ROWS
|
||||||
|
elif v > 0:
|
||||||
|
out[p["name"]] = v
|
||||||
|
# 0 → 전건 (파라미터 생략)
|
||||||
|
elif v is not None and str(v) != "":
|
||||||
|
out[p["name"]] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _slice_sources(obj, line_from, line_to):
|
||||||
|
"""CODE_FIELDS 를 줄 범위로 자르고 *_LINES(전체 줄 수) 를 붙인다. include/screen 목록 안의 소스도 같이."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k in list(obj.keys()):
|
||||||
|
v = obj[k]
|
||||||
|
if k in CODE_FIELDS and isinstance(v, str):
|
||||||
|
lines = v.split("\n")
|
||||||
|
obj[k + "_LINES"] = len(lines)
|
||||||
|
lo = max(1, line_from or 1)
|
||||||
|
hi = min(len(lines), line_to or len(lines))
|
||||||
|
if lo > 1 or hi < len(lines):
|
||||||
|
obj[k] = "\n".join(lines[lo - 1:hi])
|
||||||
|
obj[k + "_RANGE"] = f"{lo}-{hi}"
|
||||||
|
else:
|
||||||
|
_slice_sources(v, line_from, line_to)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for x in obj:
|
||||||
|
_slice_sources(x, line_from, line_to)
|
||||||
|
|
||||||
|
|
||||||
|
def _size(obj) -> int:
|
||||||
|
return len(json.dumps(obj, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def _shrink(data: dict) -> list[str]:
|
||||||
|
"""응답이 MAX_OUTPUT_CHARS 를 넘으면 그때그때 가장 큰 항목(소스 필드 또는 목록)을 절반으로 줄인다.
|
||||||
|
|
||||||
|
잘린 자리에는 *_CUT / *_TOTAL 표식을 남기고, 마지막에 표식을 모아 설명 문장 목록으로 돌려준다.
|
||||||
|
소스는 1,000자 아래로, 목록은 1행 아래로는 줄이지 않는다.
|
||||||
|
"""
|
||||||
|
if _size(data) <= MAX_OUTPUT_CHARS:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def candidates(obj, path=""):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
if k in CODE_FIELDS and isinstance(v, str) and len(v) >= 2000:
|
||||||
|
yield len(v), obj, k, path + "/" + k
|
||||||
|
elif k in LIST_FIELDS and isinstance(v, list) and len(v) > 1:
|
||||||
|
yield _size(v), obj, k, path + "/" + k
|
||||||
|
yield from candidates(v, path + "/" + k)
|
||||||
|
else:
|
||||||
|
yield from candidates(v, path + "/" + k)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for i, x in enumerate(obj):
|
||||||
|
yield from candidates(x, f"{path}[{i}]")
|
||||||
|
|
||||||
|
for _ in range(400):
|
||||||
|
if _size(data) <= MAX_OUTPUT_CHARS:
|
||||||
|
break
|
||||||
|
cands = sorted(candidates(data), key=lambda t: -t[0])
|
||||||
|
if not cands:
|
||||||
|
break
|
||||||
|
_, holder, key, _ = cands[0]
|
||||||
|
v = holder[key]
|
||||||
|
if isinstance(v, str):
|
||||||
|
keep = v[: max(1000, len(v) // 2)]
|
||||||
|
keep = keep[: keep.rfind("\n") + 1] or keep
|
||||||
|
holder[key + "_CUT"] = holder.get(key + "_CUT", 0) + (v.count("\n") - keep.count("\n"))
|
||||||
|
holder[key] = keep
|
||||||
|
else:
|
||||||
|
holder.setdefault(key + "_TOTAL", len(v))
|
||||||
|
holder[key] = v[: max(1, len(v) // 2)]
|
||||||
|
|
||||||
|
# 표식을 모아 설명으로
|
||||||
|
notes, cut_sources = [], 0
|
||||||
|
|
||||||
|
def collect(obj, path=""):
|
||||||
|
nonlocal cut_sources
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
if k.endswith("_TOTAL") and k[:-6] in LIST_FIELDS:
|
||||||
|
notes.append(f"{(path + '/' + k[:-6]).lstrip('/')}: {v}행 중 앞 {len(obj[k[:-6]])}행만 남김 (max_rows 를 줄이거나 조건을 좁힐 것)")
|
||||||
|
elif k.endswith("_CUT") and k[:-4] in CODE_FIELDS:
|
||||||
|
if path.count("[") == 0:
|
||||||
|
notes.append(f"{(path + '/' + k[:-4]).lstrip('/')}: 뒤쪽 {v}줄 생략 (line_from/line_to 로 이어서 읽을 것)")
|
||||||
|
else:
|
||||||
|
cut_sources += 1
|
||||||
|
else:
|
||||||
|
collect(v, path + "/" + k)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for i, x in enumerate(obj):
|
||||||
|
collect(x, f"{path}[{i}]")
|
||||||
|
|
||||||
|
collect(data)
|
||||||
|
if cut_sources:
|
||||||
|
notes.append(f"목록 안 소스 {cut_sources}개의 뒷부분 생략 (해당 Include/화면을 개별 툴로 다시 조회하거나 line_from/line_to 사용)")
|
||||||
|
notes.insert(0, f"응답이 {MAX_OUTPUT_CHARS:,}자를 넘어 이 서버가 잘라냈습니다 (ABAP_MCP_MAX_OUTPUT_CHARS)")
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
def _call(m: dict, kwargs: dict) -> dict:
|
||||||
|
if not CONFIG["user"] or not CONFIG["password"]:
|
||||||
|
raise ToolError("SAP 접속 정보가 없습니다. .env 또는 환경변수 SAP_USER / SAP_PASS 를 설정하세요.")
|
||||||
|
params = _to_sap_params(m, kwargs)
|
||||||
|
res = sap_call(m["name"], params, timeout=TIMEOUT)
|
||||||
|
|
||||||
|
if res["error"] and res["parsed"] is None: # 네트워크/인증/파싱 — 결과 자체가 없음
|
||||||
|
hint = ""
|
||||||
|
if res.get("http_status") == 401:
|
||||||
|
hint = " (인증 실패: SAP_USER / SAP_PASS 확인)"
|
||||||
|
raise ToolError(f"{m['name']} 호출 실패{hint}: {res['error']} [HTTP {res.get('http_status')}]")
|
||||||
|
|
||||||
|
parsed = res["parsed"] if isinstance(res["parsed"], dict) else {"RESULT": res["parsed"]}
|
||||||
|
ret_raw = parsed.pop("RETURN", {}) if isinstance(parsed.get("RETURN"), dict) else {}
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"return": {
|
||||||
|
"type": res["return"]["type"],
|
||||||
|
"message": res["return"]["message"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
out["return"]["total_rows"] = int(ret_raw.get("TOTAL_ROWS") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if res["error"]: # ICF 에러 봉투 (<error>) 등: 본문은 있으나 오류
|
||||||
|
out["return"]["error"] = res["error"]
|
||||||
|
out["return"]["http_status"] = res.get("http_status")
|
||||||
|
out.update(parsed)
|
||||||
|
|
||||||
|
if m["name"] in SOURCE_METHODS:
|
||||||
|
_slice_sources(out, kwargs.get("line_from"), kwargs.get("line_to"))
|
||||||
|
notes = _shrink(out)
|
||||||
|
if notes:
|
||||||
|
out["_truncated"] = notes
|
||||||
|
out["_meta"] = {"sap_method": m["name"], "sap_params": params, "ms": res["ms"], "raw_bytes": res.get("raw_size")}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tool(m: dict):
|
||||||
|
async def tool(**kwargs):
|
||||||
|
return await anyio.to_thread.run_sync(_call, m, kwargs)
|
||||||
|
|
||||||
|
tool.__name__ = m["name"].lower()
|
||||||
|
tool.__doc__ = _tool_description(m)
|
||||||
|
tool.__signature__ = _build_signature(m) # inspect.signature 가 이것을 읽는다
|
||||||
|
tool.__annotations__ = {p.name: p.annotation for p in tool.__signature__.parameters.values()}
|
||||||
|
tool.__annotations__["return"] = dict[str, Any]
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
|
for _m in METHODS:
|
||||||
|
mcp.add_tool(_make_tool(_m), name=_m["name"].lower(), title=_m["title"])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 보조 툴
|
||||||
|
@mcp.tool(title="필드 사전 / 코드값 해석")
|
||||||
|
def explain_fields(
|
||||||
|
fields: Annotated[list[str] | None, Field(description="응답 필드명 목록 (예: [\"SUBC\", \"OBJECT\", \"TRFUNCTION\"]). 생략하면 전체 사전")] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""응답 컬럼의 한국어 뜻과 코드값 해석표를 돌려준다 (SAP 을 모르는 사용자에게 결과를 설명할 때).
|
||||||
|
|
||||||
|
예: SUBC=1 은 실행 프로그램, OBJECT=FUGR 은 함수그룹, TRFUNCTION=K 는 워크벤치 요청.
|
||||||
|
"""
|
||||||
|
names = [f.upper() for f in fields] if fields else sorted(FIELDS)
|
||||||
|
out = {}
|
||||||
|
for n in names:
|
||||||
|
entry: dict[str, Any] = {}
|
||||||
|
if n in FIELDS:
|
||||||
|
entry["label"], entry["desc"] = FIELDS[n]
|
||||||
|
if n in VALUE_MAPS:
|
||||||
|
entry["values"] = VALUE_MAPS[n]
|
||||||
|
out[n] = entry or "사전에 없음"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(title="접속 상태 / 호출 규약")
|
||||||
|
def sap_connection_info() -> dict[str, Any]:
|
||||||
|
"""현재 SAP 접속 대상과 호출 규약을 보여준다 (비밀번호는 제외). 툴이 X 오류를 낼 때 먼저 확인."""
|
||||||
|
return {
|
||||||
|
"base_url": CONFIG["base_url"],
|
||||||
|
"user": CONFIG["user"] or "(미설정)",
|
||||||
|
"password_set": bool(CONFIG["password"]),
|
||||||
|
"verify_ssl": CONFIG["verify_ssl"],
|
||||||
|
"default_max_rows": DEFAULT_MAX_ROWS,
|
||||||
|
"max_output_chars": MAX_OUTPUT_CHARS,
|
||||||
|
"conventions": CATALOG["conventions"],
|
||||||
|
"tools": [m["name"].lower() for m in METHODS],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 실행
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if args and args[0] == "--http":
|
||||||
|
port = int(args[1]) if len(args) > 1 else 8766
|
||||||
|
print(f"abap-sap MCP (streamable-http) -> http://127.0.0.1:{port}/mcp", file=sys.stderr)
|
||||||
|
mcp.run(transport="streamable-http", host="127.0.0.1", port=port)
|
||||||
|
else:
|
||||||
|
# stdio: stdout 은 프로토콜 채널이므로 로그는 stderr 로만
|
||||||
|
print(f"abap-sap MCP (stdio) SAP={CONFIG['base_url']} user={CONFIG['user'] or '(미설정)'}", file=sys.stderr)
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# MCP 서버(mcp_server.py) 실행에 필요하다. pip install -r requirements.txt
|
||||||
|
# (웹 테스터는 ../abap-api-tester 로 분리됐고 표준 라이브러리만 쓴다)
|
||||||
|
mcp>=2.1,<3
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ZAA_ICF (ZCL_ZAA_AGENT_API) 호출 클라이언트. MCP 서버(mcp_server.py)와 웹 테스터(../abap-api-tester/server.py)가 공유한다.
|
||||||
|
|
||||||
|
- 표준 라이브러리만 사용 (Python 3.10+)
|
||||||
|
- 접속정보: .env 파일 또는 환경변수 SAP_URL / SAP_USER / SAP_PASS / SAP_VERIFY_SSL
|
||||||
|
|
||||||
|
호출 규약 (실측, README 참고):
|
||||||
|
POST {SAP_URL}/{METHOD}, Content-Type: application/json, 바디 {"INPUT": {"IV_...": ...}}
|
||||||
|
응답 asXML. XML 선언은 utf-16 이지만 실제 UTF-8 → BOM/선언 제거 후 파싱. 소스 안 제어문자(0x0C) 제거.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try: # abap-indexing: 패키지(sap.sap_client)로도, 스크립트(mcp_server.py 옆)로도 import 되게
|
||||||
|
from .catalog import LIST_FIELDS
|
||||||
|
except ImportError:
|
||||||
|
from catalog import LIST_FIELDS
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_URL = "https://pwcs4h.pwcc.co.kr:44310/sap/bc/ZAA_ICF"
|
||||||
|
|
||||||
|
|
||||||
|
def load_env():
|
||||||
|
# abap-indexing: sap/.env 다음에 저장소 루트 .env 도 본다 (LLM 키와 같은 파일에 SAP_* 를 둔다)
|
||||||
|
for p in (ROOT / ".env", ROOT.parent / ".env"):
|
||||||
|
if not p.exists():
|
||||||
|
continue
|
||||||
|
for line in p.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||||
|
|
||||||
|
|
||||||
|
load_env()
|
||||||
|
CONFIG = {
|
||||||
|
"base_url": os.environ.get("SAP_URL", DEFAULT_URL).rstrip("/"),
|
||||||
|
"user": os.environ.get("SAP_USER", ""),
|
||||||
|
"password": os.environ.get("SAP_PASS", ""),
|
||||||
|
"verify_ssl": os.environ.get("SAP_VERIFY_SSL", "0") == "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ssl_context():
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
if not CONFIG["verify_ssl"]:
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ XML -> JSON
|
||||||
|
def xml_to_data(el):
|
||||||
|
"""asXML 요소를 dict/list/str 로 변환.
|
||||||
|
|
||||||
|
행 반복은 list 로: 보통 <item> 이지만, 행 타입이 DDIC 구조인 테이블(RPY 의 파라미터 목록 등)은
|
||||||
|
<RSIMP>, <RSEXP>, <RSTBL> 처럼 구조 이름이 행 태그가 된다. 같은 태그가 반복되거나
|
||||||
|
부모가 알려진 테이블 필드(LIST_FIELDS)면 list 로 본다. (2026-09-04: 마지막 행만 남던 버그 수정)
|
||||||
|
"""
|
||||||
|
children = list(el)
|
||||||
|
if not children:
|
||||||
|
return el.text or ""
|
||||||
|
tags = {c.tag for c in children}
|
||||||
|
if len(tags) == 1 and (children[0].tag == "item" or len(children) > 1 or el.tag in LIST_FIELDS):
|
||||||
|
return [xml_to_data(c) for c in children]
|
||||||
|
out = {}
|
||||||
|
for c in children:
|
||||||
|
out[c.tag] = xml_to_data(c)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_lists(obj):
|
||||||
|
"""빈 테이블 <RESULT/> 은 "" 로 오므로 알려진 테이블 필드는 [] 로 통일."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in list(obj.items()):
|
||||||
|
if k in LIST_FIELDS and v == "":
|
||||||
|
obj[k] = []
|
||||||
|
else:
|
||||||
|
obj[k] = coerce_lists(v)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
obj = [coerce_lists(x) for x in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_xml(raw: bytes) -> str:
|
||||||
|
"""서버 응답 정규화: BOM 제거, 잘못된 utf-16 선언 제거, XML 1.0 비허용 제어문자 제거."""
|
||||||
|
text = raw.decode("utf-8", errors="replace").lstrip("")
|
||||||
|
return normalize_xml_text(text)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_xml_text(text: str) -> str:
|
||||||
|
"""이미 문자열로 디코딩된 응답에 같은 정규화를 적용 (Bruno 가 보내온 본문용)."""
|
||||||
|
text = text.lstrip("")
|
||||||
|
text = re.sub(r"^<\?xml[^>]*\?>", "", text)
|
||||||
|
return "".join(ch for ch in text if ord(ch) >= 0x20 or ch in "\t\n\r")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_asxml(text: str) -> dict:
|
||||||
|
"""정규화된 asXML 텍스트 -> {parsed, return, error}. sap_call 과 /api/render 가 공유."""
|
||||||
|
out = {"parsed": None, "return": {"type": "?", "message": ""}, "error": None}
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(text)
|
||||||
|
except ET.ParseError as e:
|
||||||
|
out["error"] = f"XML 파싱 실패: {e}"
|
||||||
|
out["return"] = {"type": "X", "message": out["error"]}
|
||||||
|
return out
|
||||||
|
|
||||||
|
if root.tag == "error": # ICF 핸들러 에러 봉투 (Method 없음 / ABAP 덤프)
|
||||||
|
err = xml_to_data(root)
|
||||||
|
out["parsed"] = {"error": err}
|
||||||
|
out["error"] = f"{err.get('code')}: {err.get('message')}"
|
||||||
|
out["return"] = {"type": "X", "message": out["error"]}
|
||||||
|
return out
|
||||||
|
|
||||||
|
data_el = root.find(".//DATA")
|
||||||
|
data = coerce_lists(xml_to_data(data_el)) if data_el is not None else {}
|
||||||
|
out["parsed"] = data
|
||||||
|
ret = data.get("RETURN") if isinstance(data, dict) else None
|
||||||
|
if isinstance(ret, dict):
|
||||||
|
out["return"] = {"type": ret.get("TYPE") or "-", "message": ret.get("MESSAGE") or ""}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ SAP 호출
|
||||||
|
def sap_call(method: str, params: dict, timeout: int = 180) -> dict:
|
||||||
|
url = f"{CONFIG['base_url']}/{method}"
|
||||||
|
body_obj = {"INPUT": {k: v for k, v in params.items() if v not in ("", None)}}
|
||||||
|
body = json.dumps(body_obj, ensure_ascii=False)
|
||||||
|
auth = "Basic " + base64.b64encode(f"{CONFIG['user']}:{CONFIG['password']}".encode()).decode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=body.encode("utf-8"), method="POST",
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": auth},
|
||||||
|
)
|
||||||
|
curl = (f"curl -sk -u '{CONFIG['user']}:****' -H 'Content-Type: application/json' "
|
||||||
|
f"--data '{body}' '{url}'")
|
||||||
|
result = {
|
||||||
|
"method": method,
|
||||||
|
"request": {"url": url, "body": body_obj, "curl": curl},
|
||||||
|
"http_status": None, "ms": None, "raw": "", "parsed": None,
|
||||||
|
"return": {"type": "?", "message": ""}, "error": None,
|
||||||
|
}
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, context=ssl_context(), timeout=timeout) as r:
|
||||||
|
raw, status = r.read(), r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw, status = e.read(), e.code
|
||||||
|
except Exception as e: # 네트워크/인증 등
|
||||||
|
result["ms"] = int((time.time() - t0) * 1000)
|
||||||
|
result["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
result["return"] = {"type": "X", "message": result["error"]}
|
||||||
|
return result
|
||||||
|
result["ms"] = int((time.time() - t0) * 1000)
|
||||||
|
result["http_status"] = status
|
||||||
|
raw_text = raw.decode("utf-8", errors="replace")
|
||||||
|
result["raw"] = raw_text
|
||||||
|
result["raw_size"] = len(raw)
|
||||||
|
result.update(parse_asxml(normalize_xml(raw)))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MCP 서버 연쇄 확인: stdio 로 mcp_server.py 를 띄워 툴 목록과 몇 가지 실제 호출을 점검한다.
|
||||||
|
|
||||||
|
python tools/test_mcp.py # 기본 케이스
|
||||||
|
python tools/test_mcp.py --shrink # 응답 상한을 15,000자로 낮춰 잘라내기 동작까지
|
||||||
|
|
||||||
|
접속 정보는 .env (SAP_USER / SAP_PASS). mcp 패키지 필요 (pip install -r requirements.txt).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mcp.client import Client
|
||||||
|
from mcp.client.stdio import StdioServerParameters
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
CASES = [
|
||||||
|
("sap_connection_info", {}),
|
||||||
|
("explain_fields", {"fields": ["SUBC", "TRFUNCTION"]}),
|
||||||
|
("get_package_list", {"max_rows": 3}), # W + total_rows
|
||||||
|
("get_tcode_info", {"tcode": "ZSCAL"}),
|
||||||
|
("get_program_source", {"program": "ZFI1000", "line_from": 1, "line_to": 5}),
|
||||||
|
("get_program_source", {"program": "SAPMZSFT0", "with_include": True, "with_screen": True, "line_to": 4}),
|
||||||
|
("get_function_detail", {"function": "ZFI_CHECK_STCD2", "line_to": 3}),
|
||||||
|
("get_version_source", {"objname": "LZLEASE01TOP", "objtype": "REPS", "versno": "00001", "line_to": 2}),
|
||||||
|
("get_where_used_list", {"obj_type": "TABL", "obj_name": "ZFIT0000"}),
|
||||||
|
("get_table_fields", {"table": "ZZZ_NOPE"}), # E 응답 (툴 오류 아님)
|
||||||
|
("get_cts_list", {"date_from": "2026-01-01"}), # 필수 누락 → 툴 오류
|
||||||
|
]
|
||||||
|
SHRINK_CASES = [
|
||||||
|
("get_program_source", {"program": "SAPMV45A", "with_include": True}), # 원문 4MB
|
||||||
|
("get_object_list_by_package", {"package": "ZFI01", "obj_type": "PROG", "max_rows": 0}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def brief(d):
|
||||||
|
if isinstance(d, dict):
|
||||||
|
return {k: (f"<str {len(v)}>" if isinstance(v, str) and len(v) > 60 else brief(v)) for k, v in d.items()}
|
||||||
|
if isinstance(d, list):
|
||||||
|
return f"<list {len(d)}>" + (" first=" + json.dumps(brief(d[0]), ensure_ascii=False)[:160] if d else "")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def main(shrink: bool):
|
||||||
|
env = dict(os.environ)
|
||||||
|
if shrink:
|
||||||
|
env["ABAP_MCP_MAX_OUTPUT_CHARS"] = "15000"
|
||||||
|
params = StdioServerParameters(command=sys.executable, args=[str(ROOT / "mcp_server.py")], cwd=str(ROOT), env=env)
|
||||||
|
fails = 0
|
||||||
|
async with Client(params) as c:
|
||||||
|
tools = (await c.list_tools()).tools
|
||||||
|
print(f"tools: {len(tools)} {[t.name for t in tools]}")
|
||||||
|
for name, args in (SHRINK_CASES if shrink else CASES):
|
||||||
|
r = await c.call_tool(name, args)
|
||||||
|
txt = r.content[0].text if r.content else ""
|
||||||
|
print(f"\n=== {name} {json.dumps(args, ensure_ascii=False)} error={r.is_error} chars={len(txt):,}")
|
||||||
|
if r.is_error:
|
||||||
|
print(" ", txt.splitlines()[0][:200])
|
||||||
|
continue
|
||||||
|
d = json.loads(txt)
|
||||||
|
print(" ", json.dumps(brief(d), ensure_ascii=False)[:700])
|
||||||
|
if d.get("_truncated"):
|
||||||
|
for n in d["_truncated"]:
|
||||||
|
print(" -", n)
|
||||||
|
if shrink and len(txt) > 15000:
|
||||||
|
fails += 1
|
||||||
|
print(" !! 상한 초과")
|
||||||
|
print("\nOK" if not fails else f"\nFAIL {fails}")
|
||||||
|
return fails
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main("--shrink" in sys.argv)))
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Stage 0 (ingest/from_sap.py) — SAP 응답을 가짜로 넣어 저장 형식과 normalize 연결을 확인한다.
|
||||||
|
|
||||||
|
실제 SAP 은 부르지 않는다. 응답 모양은 ../abap-api-tester/tools/spec/samples 의 실측 JSON 과 같다.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ingest import from_sap
|
||||||
|
from ingest.normalize import classify, parse_collected_file, run as normalize_run
|
||||||
|
|
||||||
|
PROGRAM_LIST = {
|
||||||
|
"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": "2"},
|
||||||
|
"RESULT": [
|
||||||
|
{"OBJ_NAME": "ZFIR0010", "TEXT": "거래처 I/F 이력", "SUBC": "1", "UDAT": "2024-01-02", "UNAM": "U1"},
|
||||||
|
{"OBJ_NAME": "SAPMZSFT0", "TEXT": "달력 유지보수", "SUBC": "M", "UDAT": "2020-10-16", "UNAM": "U2"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SOURCES = {
|
||||||
|
"ZFIR0010": {
|
||||||
|
"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": "0"},
|
||||||
|
"PROGRAM": "ZFIR0010",
|
||||||
|
"SOURCE_CODE": "REPORT zfir0010.\nINCLUDE zfir0010_top.\nSTART-OF-SELECTION.\n PERFORM main.\n",
|
||||||
|
"INCLUDE_LIST": [
|
||||||
|
{"INCL_NAME": "ZFIR0010_TOP", "SOURCE": "DATA gv_bukrs TYPE bukrs.\n"},
|
||||||
|
{"INCL_NAME": "DB__SSEL", "SOURCE": ""}, # 빈 소스는 버린다
|
||||||
|
{"INCL_NAME": "ZFIR0010_TOP", "SOURCE": "DATA dup.\n"}, # 중복은 버린다
|
||||||
|
],
|
||||||
|
"SCREEN_LIST": "",
|
||||||
|
"TCODE_LIST": [{"TCODE": "ZFIR0010", "TTEXT": "거래처 I/F 이력조회", "PGMNA": "ZFIR0010"}],
|
||||||
|
},
|
||||||
|
"SAPMZSFT0": {
|
||||||
|
"RETURN": {"TYPE": "E", "MESSAGE": "프로그램 소스를 읽을 수 없습니다", "TOTAL_ROWS": "0"},
|
||||||
|
"PROGRAM": "SAPMZSFT0", "SOURCE_CODE": "", "INCLUDE_LIST": "", "SCREEN_LIST": "", "TCODE_LIST": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fake_sap_call(method: str, params: dict, timeout: int = 180) -> dict:
|
||||||
|
if method == "GET_PROGRAM_LIST":
|
||||||
|
assert params["IV_PACKAGE"] == "ZFI01"
|
||||||
|
data = PROGRAM_LIST
|
||||||
|
elif method == "GET_PROGRAM_SOURCE":
|
||||||
|
assert params["IV_WITH_INCLUDE"] == "X"
|
||||||
|
data = SOURCES[params["IV_PROGRAM"]]
|
||||||
|
else:
|
||||||
|
raise AssertionError(method)
|
||||||
|
ret = data["RETURN"]
|
||||||
|
return {"method": method, "parsed": data, "error": None,
|
||||||
|
"return": {"type": ret["TYPE"], "message": ret["MESSAGE"]}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sap(monkeypatch):
|
||||||
|
import sap.sap_client as client
|
||||||
|
monkeypatch.setattr(client, "sap_call", fake_sap_call)
|
||||||
|
monkeypatch.setattr(from_sap.time, "sleep", lambda s: None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_writes_raw_files_and_records_errors(tmp_path, sap):
|
||||||
|
stats = from_sap.run(["zfi01"], tmp_path)
|
||||||
|
assert stats == {"packages": 1, "listed": 2, "fetched": 1, "skipped": 0, "errors": 1, "includes": 2}
|
||||||
|
|
||||||
|
# 패키지 목록: normalize 가 package_list 로 분류하는 모양
|
||||||
|
pkg = parse_collected_file((tmp_path / "ZFI01.txt").read_text(encoding="utf-8"))
|
||||||
|
assert classify(pkg) == "package_list"
|
||||||
|
assert pkg[0] == {"DEVCLASS": "ZFI01", "OBJ_NAME": "ZFIR0010", "TEXT": "거래처 I/F 이력",
|
||||||
|
"CREATED_ON": "", "CHANGED_ON": "2024-01-02"}
|
||||||
|
|
||||||
|
# 프로그램 소스: 메인 + 인클루드(빈 것·중복 제외), 줄바꿈은 JSON 이스케이프로 보존
|
||||||
|
prog = parse_collected_file((tmp_path / "ZFIR0010.txt").read_text(encoding="utf-8"))
|
||||||
|
assert classify(prog) == "program_source"
|
||||||
|
assert prog["DESCRIPTION"] == "거래처 I/F 이력"
|
||||||
|
assert [i["INCLUDE"] for i in prog["INCLUDE_PROGRAM"]] == ["ZFIR0010", "ZFIR0010_TOP"]
|
||||||
|
assert prog["INCLUDE_PROGRAM"][0]["SOURCE_CODE"].splitlines()[0] == "REPORT zfir0010."
|
||||||
|
assert prog["TEXT_SYMBOL"] == []
|
||||||
|
assert prog["TCODE_LIST"][0]["TCODE"] == "ZFIR0010"
|
||||||
|
|
||||||
|
# 실패한 프로그램은 파일 없이 _errors.log 에
|
||||||
|
assert not (tmp_path / "SAPMZSFT0.txt").exists()
|
||||||
|
assert "SAPMZSFT0\tSOURCE\tSAP 오류" in (tmp_path / "_errors.log").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerun_skips_existing_unless_force(tmp_path, sap):
|
||||||
|
from_sap.run(["ZFI01"], tmp_path)
|
||||||
|
again = from_sap.run(["ZFI01"], tmp_path)
|
||||||
|
assert again["skipped"] == 1 and again["fetched"] == 0
|
||||||
|
forced = from_sap.run(["ZFI01"], tmp_path, force=True)
|
||||||
|
assert forced["fetched"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_consumes_output_and_builds_tcodes(tmp_path, sap):
|
||||||
|
raw, out = tmp_path / "raw", tmp_path / "normalized"
|
||||||
|
from_sap.run(["ZFI01"], raw)
|
||||||
|
stats = normalize_run(raw, out)
|
||||||
|
assert stats["programs"] == 1 and stats["package_rows"] == 2 and stats["errors"] == 0
|
||||||
|
|
||||||
|
assert (out / "ZFIR0010" / "ZFIR0010.abap").read_text(encoding="utf-8").startswith("REPORT zfir0010.\n")
|
||||||
|
assert (out / "ZFIR0010" / "ZFIR0010_TOP.abap").exists()
|
||||||
|
meta = json.loads((out / "ZFIR0010" / "ZFIR0010.meta.json").read_text(encoding="utf-8"))
|
||||||
|
assert meta["description"] == "거래처 I/F 이력" and len(meta["includes"]) == 2
|
||||||
|
|
||||||
|
tcodes = [json.loads(l) for l in (out / "tcodes.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||||
|
assert tcodes == [{"tcode": "ZFIR0010", "program": "ZFIR0010", "text_ko": "거래처 I/F 이력조회"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_writes_nothing(tmp_path, sap):
|
||||||
|
stats = from_sap.run(["ZFI01"], tmp_path, dry_run=True)
|
||||||
|
assert stats["listed"] == 2 and stats["fetched"] == 0
|
||||||
|
assert list(tmp_path.iterdir()) == []
|
||||||
Reference in New Issue
Block a user