diff --git a/Z01_MasterData/Z01_MasterData_Tables.py b/Z01_MasterData/Z01_MasterData_Tables.py index 1d544f90..852cbb95 100644 --- a/Z01_MasterData/Z01_MasterData_Tables.py +++ b/Z01_MasterData/Z01_MasterData_Tables.py @@ -6,6 +6,7 @@ 표 = **화면이 표로 그릴 마디** — 이름표 생성기와 같은 잣대(브레인 「91 이 정본」): · 딕셔너리 목록(비지 않음) → 줄 = 딕셔너리 그대로({열key: 값}) · 딕셔너리 둘 이상이 모인 딕셔너리(열이 비슷함) → 줄마다 `@key` 열(그 줄의 이름) + 값 딕셔너리 + · 표 아닌 마디(값 묶음 — 산재 3.56% 같은 낱값)는 파일마다 표 하나 `@values`(이름 · 값) — 설명·방침은 뺌(브레인 ④) ⚠ 표 id 가 이름표와 한 글자라도 다르면 이름이 통째로 안 붙음 — `test_z01_master_data.py` 가 양쪽으로 셈. """ @@ -22,6 +23,18 @@ LABEL_DIR = RESOURCES / "data_master_labels" DEFAULT_PAGE_SIZE = 50 MAX_PAGE_SIZE = 500 ROW_KEY = "@key" +VALUES_TABLE = "@values" +_CODE_HIDDEN = {"@path"} # 낱값 표의 영문 자리 — 이름표에 없을 때만 + +# ⚠ 잠정 — 이름표가 값 묶음마다 「값이냐 설명이냐」 표시를 주면 **이 자리만** 갈아끼움(브레인 ④) +_DESCRIPTION_KEYS = {"note", "notes", "policy", "source", "sources", "quote", "why"} + + +def is_description(group_key: str) -> bool: + """값 묶음이 설명·방침인가(값이 아님) — 끝 조각으로 가름.""" + last = group_key.rsplit("/", 1)[-1] + return last in _DESCRIPTION_KEYS or last.endswith("_note") + # 파일 머리 — 표도 값 묶음도 아님(이름표 생성기와 같은 목록) META_KEYS = { @@ -57,7 +70,9 @@ def _has_table(node: Any, depth: int = 0) -> bool: return any(_has_table(v, depth + 1) for v in node.values()) -def _collect(node: Any, path: list[str], out: dict[str, list], depth: int = 0) -> None: +def _collect( + node: Any, path: list[str], out: dict[str, list], values: dict, depth: int = 0 +) -> None: shape = _is_table(node) if shape == "list": out["/".join(path)] = node @@ -65,22 +80,45 @@ def _collect(node: Any, path: list[str], out: dict[str, list], depth: int = 0) - out["/".join(path)] = [{ROW_KEY: k, **v} for k, v in node.items()] elif depth <= 5 and isinstance(node, dict) and any(_has_table(v) for v in node.values()): for key, value in node.items(): - _collect(value, [*path, key], out, depth + 1) + _collect(value, [*path, key], out, values, depth + 1) + else: + values["/".join(path)] = node @lru_cache(maxsize=64) -def _tables_of(path: str, _mtime_ns: int) -> dict[str, list[dict[str, Any]]]: +def _scan(path: str, _mtime_ns: int) -> tuple[dict[str, list[dict[str, Any]]], dict[str, Any]]: doc = json.loads(Path(path).read_text(encoding="utf-8")) out: dict[str, list[dict[str, Any]]] = {} + values: dict[str, Any] = {} for key, value in doc.items(): if key not in META_KEYS: - _collect(value, [key], out) - return out + _collect(value, [key], out, values) + return out, values def tables_of(path: Path) -> dict[str, list[dict[str, Any]]]: """표 id(이름표의 표 key · `/` 로 이은 자리) → 줄.""" - return _tables_of(str(path), path.stat().st_mtime_ns) + return _scan(str(path), path.stat().st_mtime_ns)[0] + + +def values_of(path: Path) -> dict[str, Any]: + """값 묶음 자리(이름표의 값 묶음 key) → 값 — 설명·방침도 섞임.""" + return _scan(str(path), path.stat().st_mtime_ns)[1] + + +def _value_rows(fid: str, path: Path, labels: dict[str, Any]) -> list[dict[str, Any]]: + """낱값 표 줄 — 찾는 차례: 「파일id::key」 → 「끝 조각」 → 영문 key.""" + rows = [] + for key, value in values_of(path).items(): + if is_description(key): + continue + named = ( + (labels.get("value_overrides") or {}).get(f"{fid}::{key}") + or (labels.get("value_keys") or {}).get(key.rsplit("/", 1)[-1]) + or {} + ) + rows.append({"@name": named.get("name_ko") or key, "@value": value, "@path": key}) + return rows @lru_cache(maxsize=4) @@ -114,14 +152,18 @@ def tree() -> dict[str, Any]: named = {t["key"]: t.get("name_ko") or "" for t in entry.get("tables") or []} kind = entry.get("kind") or "" group = groups.setdefault(kind, {"key": kind, "label": kind, "files": []}) + counts = {tid: len(rows) for tid, rows in tables_of(path).items()} + value_count = len(_value_rows(entry["file_id"], path, labels)) + if value_count: + counts[VALUES_TABLE] = value_count group["files"].append( { "id": entry["file_id"], "label": entry.get("name_ko") or entry["file_id"], "key": path.name, "tables": [ - {"id": tid, "label": named.get(tid) or tid, "key": tid, "row_count": len(rows)} - for tid, rows in tables_of(path).items() + {"id": tid, "label": named.get(tid) or tid, "key": tid, "row_count": n} + for tid, n in counts.items() ], } ) @@ -135,7 +177,10 @@ def rows( labels = load_labels() entry = next((f for f in labels["files"] if f["file_id"] == fid), None) path = _file_path(entry) if entry else None - table = tables_of(path).get(tid) if path else None + if path and tid == VALUES_TABLE: + table = _value_rows(fid, path, labels) or None + else: + table = tables_of(path).get(tid) if path else None if table is None: return None keys: dict[str, None] = {} @@ -157,7 +202,7 @@ def rows( "key": key, "label": named.get("name_ko") or key, "unit": named.get("unit") or "", - "hidden": named.get("visible") is False, + "hidden": named.get("visible") is False if named else key in _CODE_HIDDEN, } ) return {"columns": columns, "rows": hits[start : start + size], "total": len(hits)} diff --git a/resources/tester/test_z01_master_data.py b/resources/tester/test_z01_master_data.py index 8be5f053..7b5d77ef 100644 --- a/resources/tester/test_z01_master_data.py +++ b/resources/tester/test_z01_master_data.py @@ -63,19 +63,22 @@ def test_갈래는_이름표_차례_파일_34_강우_캐시는_없음(client: Te def test_트리_표_id_와_이름표_표_id_는_양쪽으로_같음(client: TestClient) -> None: - files = _files(client) + files = { # 낱값 표 `@values` 는 예약어 — 이름표 표 목록 밖(아래 시험이 따로 셈) + fid: [t for t in f["tables"] if t["id"] != tables.VALUES_TABLE] + for fid, f in _files(client).items() + } not_labelled, label_only = [], [] for entry in LABELS["files"]: - tree_ids = {t["id"] for t in files[entry["file_id"]]["tables"]} + tree_ids = {t["id"] for t in files[entry["file_id"]]} label_ids = {t["key"] for t in entry["tables"]} not_labelled += [f"{entry['file_id']}::{i}" for i in sorted(tree_ids - label_ids)] label_only += [f"{entry['file_id']}::{i}" for i in sorted(label_ids - tree_ids)] assert not_labelled == [], not_labelled assert label_only == [], label_only - assert sum(len(f["tables"]) for f in files.values()) == LABELS["counts"]["tables"] == 91 + assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 91 for entry in LABELS["files"]: # 이름이 실제로 붙음 · 줄 수도 이름표 셈과 같음 named = {t["key"]: t for t in entry["tables"]} - for t in files[entry["file_id"]]["tables"]: + for t in files[entry["file_id"]]: assert t["label"] == named[t["id"]]["name_ko"], (entry["file_id"], t["id"]) assert t["row_count"] == named[t["id"]]["rows"], (entry["file_id"], t["id"]) @@ -164,6 +167,50 @@ def test_찾는_차례와_없는_이름은_영문_key( ) +def test_낱값은_파일마다_표_하나_설명류는_뺌(client: TestClient) -> None: + """브레인 ④ — 표 아닌 마디(값 묶음)도 트리에 `@values` 한 마디로 · 설명·방침은 값이 아님.""" + files = _files(client) + rates = files["rates"]["tables"] + assert rates[-1]["id"] == "@values" + values = _rows(client, file="rates", table="@values", size=500) + assert values["total"] == rates[-1]["row_count"] + by_path = {r["@path"]: r for r in values["rows"]} + sanjae = by_path["variables/rate_sanjae"] + assert sanjae["@value"] == {"rate_percent": 3.56, "base": "total_labor_cost"} + assert sanjae["@name"] == "산재보험료" + assert {c["key"]: c["hidden"] for c in values["columns"]} == { + "@name": False, + "@value": False, + "@path": True, + } + master = {r["@path"] for r in _rows(client, file="work_item_master", table="@values")["rows"]} + assert master == {"stats"} # policy 는 방침 + assert "@values" not in {t["id"] for t in files["aliases"]["tables"]} # note 뿐 + assert "@values" not in {t["id"] for t in files["fx"]["tables"]} # 값 묶음 없음 + for f in files.values(): + if "@values" in {t["id"] for t in f["tables"]}: + paths = [ + r["@path"] for r in _rows(client, file=f["id"], table="@values", size=500)["rows"] + ] + assert not [p for p in paths if tables.is_description(p)], f["id"] + hit = _rows(client, file="rates", table="@values", q="산재") + assert [r["@path"] for r in hit["rows"]] == ["variables/rate_sanjae"] + + +def test_값_묶음_자리는_이름표와_양쪽으로_같음() -> None: + not_labelled, label_only = [], [] + for entry in LABELS["files"]: + ours = set(tables.values_of(tables.ROOT / entry["path"])) + theirs = {g["key"] for g in entry["value_groups"]} + not_labelled += [f"{entry['file_id']}::{k}" for k in sorted(ours - theirs)] + label_only += [f"{entry['file_id']}::{k}" for k in sorted(theirs - ours)] + assert not_labelled == [] and label_only == [], (not_labelled, label_only) + for entry in LABELS["files"]: # 이름도 이름표가 푼 그대로(찾는 차례 「파일id::key」 → 끝 조각) + named = {g["key"]: g["name_ko"] for g in entry["value_groups"]} + rows = tables._value_rows(entry["file_id"], tables.ROOT / entry["path"], LABELS) + assert [r["@name"] for r in rows] == [named[r["@path"]] for r in rows], entry["file_id"] + + def test_없는_파일_표는_404(client: TestClient) -> None: for params in ( {"file": "nope", "table": "rows"},