From fdae34161e0e36538d48c5d249f1c59f125ac91b Mon Sep 17 00:00:00 2001 From: "byeongwook.choi" Date: Tue, 22 Sep 2026 10:29:46 +0900 Subject: [PATCH] =?UTF-8?q?from=5Fsap:=20=ED=8E=91=EC=85=98=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9(=ED=8E=91=EC=85=98=20=EB=AA=A8=EB=93=88=20=EC=86=8C?= =?UTF-8?q?=EC=8A=A4)=EB=8F=84=20=EB=B0=9B=EB=8A=94=EB=8B=A4=20=E2=80=94?= =?UTF-8?q?=20SAPL<=EA=B7=B8=EB=A3=B9>=20+=20INCLUDE=20=EB=AC=B8=20?= =?UTF-8?q?=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET_PROGRAM_LIST 는 TADIR 의 PROG 만 주므로 GET_OBJECT_LIST_BY_PACKAGE(FUGR) 로 그룹을 받아 SAPL<그룹> 을 프로그램처럼 저장 (설명에 펑션 이름·텍스트 목록) - 펑션 그룹은 서버 INCLUDE_LIST 가 비어 온다(실측) → 소스의 INCLUDE 문을 깊이 4 까지 따라가며 고객 인클루드(Z/Y/네임스페이스, L<그룹>…)만 받는다. 표준 생성기 인클루드(LSVIM…)는 제외 - 실측 ZFI01: 펑션 그룹 14, SAPLZFG_FI01 인클루드 47 → 파서 FUNCTION 38 unit, 미인식 0 - --no-fugr 로 끌 수 있다 Co-Authored-By: Claude Fable 5.1 --- ingest/from_sap.py | 106 +++++++++++++++++++++++++++++++++++++++-- tests/test_from_sap.py | 74 ++++++++++++++++++++++++---- 2 files changed, 168 insertions(+), 12 deletions(-) diff --git a/ingest/from_sap.py b/ingest/from_sap.py index 2f068fa..b6cdaf8 100644 --- a/ingest/from_sap.py +++ b/ingest/from_sap.py @@ -19,6 +19,9 @@ ZAA_ICF 서비스(ZCL_ZAA_AGENT_API)가 설치돼 있어야 한다. INCLUDE_PROGRAM: [{INCLUDE, SOURCE_CODE}], TCODE_LIST: [...]} _errors.log 실패한 프로그램 (계속 진행하고 여기 기록) +- 펑션 모듈: 프로그램 목록 API 에는 안 나온다(TADIR 의 FUGR). 패키지의 펑션 그룹을 따로 받아 + 그룹 메인 프로그램 SAPL<그룹> 을 프로그램처럼 저장한다 (펑션 소스는 그 인클루드 L<그룹>U01 …). + 펑션 그룹은 서버 INCLUDE_LIST 가 비어 오므로 소스의 INCLUDE 문을 따라 받는다. --no-fugr 로 끈다. - SAP 목록 API 는 SUBC 를 생략하면 실행 프로그램(1)과 모듈 풀(M)만 준다. Include 는 각 프로그램의 INCLUDE_LIST 로 따라오므로 따로 받지 않는다. - 텍스트 심볼(TEXT-nnn)은 이 API 에 없다 → TEXT_SYMBOL 은 빈 배열 (from_dir 와 같은 한계). @@ -28,6 +31,7 @@ from __future__ import annotations import argparse import json +import re import sys import time from pathlib import Path @@ -69,6 +73,56 @@ def list_programs(package: str | None = None, pattern: str | None = None, return [r for r in rows if isinstance(r, dict) and r.get("OBJ_NAME")] +# 소스 안의 INCLUDE 문. 주석줄(* 로 시작)은 제외한다 +_INCLUDE_STMT = re.compile(r"^(?![ \t]*\*)[ \t]*INCLUDE[ \t]+([A-Za-z0-9_/]+)[ \t]*\.", re.I | re.M) +MAX_INCLUDE_DEPTH = 4 + + +def is_customer_include(name: str, program: str) -> bool: + """받아올 인클루드인가 — 고객 개발(Z/Y/네임스페이스)과 그 펑션 그룹의 L<그룹>… 인클루드만. + + SAP 표준(LSVIMFXX, lsvimdat 같은 생성기 인클루드)은 소스가 크고 업무 로직이 아니라 뺀다. + """ + n = name.upper() + if n.startswith(("Z", "Y", "/")): + return True + p = program.upper() + if p.startswith("SAPL") and n.startswith("L" + p[4:]): # SAPLZFG_FI01 → LZFG_FI01TOP / U01 / F01 … + return True + return False + + +def _resolve_includes_from_source(program: str, includes: list[dict], seen: set[str]) -> int: + """INCLUDE 문을 따라 인클루드를 받아 includes 에 덧붙인다 (D010INC 가 비어 있을 때의 대안). + + 펑션 그룹(SAPL*)은 서버의 INCLUDE_LIST 가 비어 온다(실측 2026-09-22). 메인 → LxxxUXX → LxxxU01 … + 처럼 단계가 있어 깊이 MAX_INCLUDE_DEPTH 까지 따라간다. 받은 개수를 돌려준다. + """ + added = 0 + frontier = list(includes) + for _ in range(MAX_INCLUDE_DEPTH): + next_frontier: list[dict] = [] + for inc in frontier: + for ref in _INCLUDE_STMT.findall(inc["SOURCE_CODE"]): + ref = ref.upper() + if ref in seen or not is_customer_include(ref, program): + continue + seen.add(ref) + res = _call("GET_PROGRAM_SOURCE", {"IV_PROGRAM": ref}) + code = ((res.get("parsed") or {}).get("SOURCE_CODE") or "") if not res.get("error") else "" + if res.get("error") or res["return"]["type"] == "E" or not code.strip(): + _log(f" include {ref} 못 받음: {res.get('error') or res['return']['message']}") + continue + item = {"INCLUDE": ref, "SOURCE_CODE": code} + includes.append(item) + next_frontier.append(item) + added += 1 + if not next_frontier: + break + frontier = next_frontier + return added + + 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"}) @@ -95,6 +149,8 @@ def fetch_program(name: str, description: str = "") -> dict: continue seen.add(inc_name) includes.append({"INCLUDE": inc_name, "SOURCE_CODE": code}) + # 서버 INCLUDE_LIST 에 없는 인클루드를 소스의 INCLUDE 문으로 보충 (펑션 그룹은 이 경로로만 받아진다) + _resolve_includes_from_source(program, includes, seen) tcodes = [ {"TCODE": (t.get("TCODE") or "").strip().upper(), "TTEXT": t.get("TTEXT") or ""} @@ -109,6 +165,35 @@ def fetch_program(name: str, description: str = "") -> dict: } +def list_function_groups(package: str) -> list[dict]: + """패키지의 펑션 그룹 → [{OBJ_NAME: 'SAPLZFG_FI01', TEXT: '펑션 그룹 ZFG_FI01: FN1, FN2 …'}] + + 펑션 모듈 소스는 그룹 메인 프로그램 SAPL<그룹> 의 인클루드(L<그룹>U01 …)에 있다. + GET_PROGRAM_LIST 는 TADIR 의 PROG 만 주므로 펑션 그룹(FUGR)은 여기서 따로 받는다. + """ + res = _call("GET_OBJECT_LIST_BY_PACKAGE", {"IV_PACKAGE": package, "IV_OBJ_TYPE": "FUGR"}) + if res.get("error"): + raise RuntimeError(res["error"]) + if res["return"]["type"] == "E": + raise RuntimeError(f"SAP 오류: {res['return']['message']}") + out = [] + for r in (res.get("parsed") or {}).get("RESULT") or []: + if not isinstance(r, dict) or not r.get("OBJ_NAME"): + continue + group = r["OBJ_NAME"].strip().upper() + # 그룹 안의 펑션 이름·설명을 프로그램 설명으로 — 펑션 이름 검색이 프로그램 단위에서도 되게 + names: list[str] = [] + fl = _call("GET_FUNCTION_LIST", {"IV_FUNC_GROUP": group}) + if not fl.get("error"): + for f in (fl.get("parsed") or {}).get("RESULT") or []: + if isinstance(f, dict) and f.get("FUNCNAME"): + text = (f.get("STEXT") or f.get("FUNC_TEXT") or "").strip() + names.append(f["FUNCNAME"].strip().upper() + (f" ({text})" if text else "")) + desc = f"펑션 그룹 {group}" + (": " + ", ".join(names[:30]) if names else "") + out.append({"OBJ_NAME": "SAPL" + group, "TEXT": desc, "SUBC": "F", "UDAT": r.get("UDAT") or ""}) + return out + + def package_rows(package: str, rows: list[dict]) -> list[dict]: """프로그램 목록 → normalize 가 packages.jsonl 로 만드는 package_list 모양.""" return [ @@ -129,9 +214,11 @@ def _write_json(path: Path, obj) -> None: 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: + limit: int | None = None, force: bool = False, dry_run: bool = False, + fugr: bool = True) -> dict: out_dir.mkdir(parents=True, exist_ok=True) - stats = {"packages": 0, "listed": 0, "fetched": 0, "skipped": 0, "errors": 0, "includes": 0} + stats = {"packages": 0, "listed": 0, "function_groups": 0, "fetched": 0, "skipped": 0, "errors": 0, + "includes": 0} errors: list[str] = [] targets: list[tuple[str, str]] = [] # (program, description) @@ -150,6 +237,17 @@ def run(packages: list[str], out_dir: Path, pattern: str | None = None, subc: st continue stats["packages"] += 1 _log(f" 프로그램 {len(rows)}건") + if fugr and not pattern: + # 펑션 그룹 → SAPL<그룹> 을 프로그램처럼 받는다 (펑션 모듈 소스가 그 인클루드에 있다) + try: + groups = list_function_groups(pkg) + _log(f" 펑션 그룹 {len(groups)}건") + stats["function_groups"] += len(groups) + rows = rows + groups + except Exception as e: # noqa: BLE001 + stats["errors"] += 1 + errors.append(f"{pkg}\tFUGR_LIST\t{e}") + _log(f" ! 펑션 그룹 목록 실패: {e}") 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] @@ -206,6 +304,8 @@ def main() -> None: 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="목록만 보고 저장하지 않는다") + ap.add_argument("--no-fugr", action="store_true", + help="펑션 그룹(SAPL<그룹>, 펑션 모듈 소스)은 받지 않는다 (기본은 같이 받음)") args = ap.parse_args() if not args.packages and not args.pattern: @@ -221,7 +321,7 @@ def main() -> None: 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) + limit=args.limit, force=args.force, dry_run=args.dry_run, fugr=not args.no_fugr) print(json.dumps(stats, ensure_ascii=False)) if stats["errors"]: print(f"실패 {stats['errors']}건 → {out_dir / '_errors.log'}", file=sys.stderr) diff --git a/tests/test_from_sap.py b/tests/test_from_sap.py index c1358e1..bbbcf6f 100644 --- a/tests/test_from_sap.py +++ b/tests/test_from_sap.py @@ -39,13 +39,41 @@ SOURCES = { } +FUGR_LIST = {"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": 1}, + "RESULT": [{"OBJECT": "FUGR", "OBJ_NAME": "ZFG_FI01", "AUTHOR": "U1"}]} +FUNC_LIST = {"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": 1}, + "RESULT": [{"FUNCNAME": "Z_FI_CHECK", "STEXT": "사업자번호 체크"}]} +# 펑션 그룹: 서버 INCLUDE_LIST 는 비어 오고, 소스의 INCLUDE 문을 따라가야 한다 (실측) +SOURCES["SAPLZFG_FI01"] = { + "RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": 0}, "PROGRAM": "SAPLZFG_FI01", + "SOURCE_CODE": "* regenerated\nINCLUDE LZFG_FI01TOP.\nINCLUDE LZFG_FI01UXX.\nINCLUDE LSVIMFXX.\n* INCLUDE LZFG_FI01ZZZ.\n", + "INCLUDE_LIST": [], "SCREEN_LIST": [], "TCODE_LIST": [], +} +SOURCES["LZFG_FI01TOP"] = {"RETURN": {"TYPE": "S", "MESSAGE": ""}, "PROGRAM": "LZFG_FI01TOP", + "SOURCE_CODE": "FUNCTION-POOL zfg_fi01.\nINCLUDE lsvimdat.\n", "INCLUDE_LIST": []} +SOURCES["LZFG_FI01UXX"] = {"RETURN": {"TYPE": "S", "MESSAGE": ""}, "PROGRAM": "LZFG_FI01UXX", + "SOURCE_CODE": "INCLUDE LZFG_FI01U01.\n", "INCLUDE_LIST": []} +SOURCES["LZFG_FI01U01"] = {"RETURN": {"TYPE": "S", "MESSAGE": ""}, "PROGRAM": "LZFG_FI01U01", + "SOURCE_CODE": "FUNCTION z_fi_check.\n SELECT SINGLE * FROM t001.\nENDFUNCTION.\n", + "INCLUDE_LIST": []} +CALLED: list[str] = [] + + def fake_sap_call(method: str, params: dict, timeout: int = 180) -> dict: + CALLED.append(f"{method}:{params.get('IV_PROGRAM') or params.get('IV_OBJ_TYPE') or params.get('IV_FUNC_GROUP') or ''}") if method == "GET_PROGRAM_LIST": assert params["IV_PACKAGE"] == "ZFI01" data = PROGRAM_LIST + elif method == "GET_OBJECT_LIST_BY_PACKAGE": + assert params["IV_OBJ_TYPE"] == "FUGR" + data = FUGR_LIST + elif method == "GET_FUNCTION_LIST": + data = FUNC_LIST elif method == "GET_PROGRAM_SOURCE": - assert params["IV_WITH_INCLUDE"] == "X" - data = SOURCES[params["IV_PROGRAM"]] + name = params["IV_PROGRAM"] + if name not in SOURCES: # 표준 인클루드(LSVIMFXX 등)는 요청 자체가 오면 안 된다 + raise AssertionError(f"요청하면 안 되는 소스: {name}") + data = SOURCES[name] else: raise AssertionError(method) ret = data["RETURN"] @@ -61,8 +89,9 @@ def sap(monkeypatch): 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} + stats = from_sap.run(["zfi01"], tmp_path, fugr=False) + assert stats == {"packages": 1, "listed": 2, "function_groups": 0, "fetched": 1, "skipped": 0, + "errors": 1, "includes": 2} # 패키지 목록: normalize 가 package_list 로 분류하는 모양 pkg = parse_collected_file((tmp_path / "ZFI01.txt").read_text(encoding="utf-8")) @@ -85,16 +114,16 @@ def test_run_writes_raw_files_and_records_errors(tmp_path, sap): def test_rerun_skips_existing_unless_force(tmp_path, sap): - from_sap.run(["ZFI01"], tmp_path) - again = from_sap.run(["ZFI01"], tmp_path) + from_sap.run(["ZFI01"], tmp_path, fugr=False) + again = from_sap.run(["ZFI01"], tmp_path, fugr=False) assert again["skipped"] == 1 and again["fetched"] == 0 - forced = from_sap.run(["ZFI01"], tmp_path, force=True) + forced = from_sap.run(["ZFI01"], tmp_path, force=True, fugr=False) 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) + from_sap.run(["ZFI01"], raw, fugr=False) stats = normalize_run(raw, out) assert stats["programs"] == 1 and stats["package_rows"] == 2 and stats["errors"] == 0 @@ -108,7 +137,7 @@ def test_normalize_consumes_output_and_builds_tcodes(tmp_path, sap): def test_dry_run_writes_nothing(tmp_path, sap): - stats = from_sap.run(["ZFI01"], tmp_path, dry_run=True) + stats = from_sap.run(["ZFI01"], tmp_path, dry_run=True, fugr=False) assert stats["listed"] == 2 and stats["fetched"] == 0 assert list(tmp_path.iterdir()) == [] @@ -172,3 +201,30 @@ def test_normalize_survives_non_string_fields_and_bad_file(tmp_path): rows = [json.loads(l) for l in (out / "packages.jsonl").read_text(encoding="utf-8").splitlines()] assert rows[0]["changed_on"] == "20240102" and rows[0]["text"] == "" and rows[1]["changed_on"] == "" assert "ZBAD.txt" in (out / "_errors.log").read_text(encoding="utf-8") + + +def test_function_groups_are_fetched_via_include_statements(tmp_path, sap): + """펑션 그룹: FUGR 목록 → SAPL<그룹> 저장, 인클루드는 INCLUDE 문을 따라(표준 인클루드는 제외, 주석은 무시).""" + CALLED.clear() + stats = from_sap.run(["ZFI01"], tmp_path) + assert stats["function_groups"] == 1 and stats["listed"] == 3 and stats["fetched"] == 2 + prog = parse_collected_file((tmp_path / "SAPLZFG_FI01.txt").read_text(encoding="utf-8")) + assert prog["MAIN_PROGRAM"] == "SAPLZFG_FI01" + assert prog["DESCRIPTION"] == "펑션 그룹 ZFG_FI01: Z_FI_CHECK (사업자번호 체크)" + assert [i["INCLUDE"] for i in prog["INCLUDE_PROGRAM"]] == [ + "SAPLZFG_FI01", "LZFG_FI01TOP", "LZFG_FI01UXX", "LZFG_FI01U01"] + assert "ENDFUNCTION" in prog["INCLUDE_PROGRAM"][3]["SOURCE_CODE"] + assert not any(c.endswith(":LSVIMFXX") or c.endswith(":LSVIMDAT") or c.endswith(":LZFG_FI01ZZZ") for c in CALLED) + # 패키지 목록에도 SAPL 프로그램이 들어간다 (package 테이블 조인용) + pkg = parse_collected_file((tmp_path / "ZFI01.txt").read_text(encoding="utf-8")) + assert any(r["OBJ_NAME"] == "SAPLZFG_FI01" for r in pkg) + + +def test_is_customer_include(): + from ingest.from_sap import is_customer_include + assert is_customer_include("LZFG_FI01U01", "SAPLZFG_FI01") + assert is_customer_include("ZFI_COMMON", "ZFI0000") + assert is_customer_include("/PWC/ZINC", "ZFI0000") + assert not is_customer_include("LSVIMFXX", "SAPLZFG_FI01") + assert not is_customer_include("LZOTHERU01", "SAPLZFG_FI01") # 다른 그룹의 L… 인클루드는 제외 + assert not is_customer_include("RSDBRUNT", "ZFI0000")