Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,9 +95,13 @@ def _scan(path: Path) -> tuple[list, list]:
|
||||
return tables, values
|
||||
|
||||
|
||||
#: 사전 모양 표의 **줄 이름 열** — API 가 이 이름으로 내보낸다(2026-09-15 브레인 ①).
|
||||
ROW_KEY_COLUMN = "@key"
|
||||
|
||||
|
||||
def _columns_of(node, shape: str) -> list[str]:
|
||||
records = node if shape == "list" else list(node.values())
|
||||
seen: list[str] = []
|
||||
seen: list[str] = [ROW_KEY_COLUMN] if shape == "map" else []
|
||||
for record in records:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
@@ -281,3 +285,87 @@ def test_세어_둔_수가_실제와_같다(labels):
|
||||
|
||||
def test_강우_IDF_캐시는_안_담았다(labels):
|
||||
assert not [f for f in labels["files"] if "rainfall" in f["path"]]
|
||||
|
||||
|
||||
def test_사전_모양_표에는_줄_이름_열이_있다(labels):
|
||||
"""`fx`·`oil` 처럼 사전 모양인 표는 **줄 이름(키) 자체가 한 열**이다.
|
||||
그 열이 없으면 화면에 영문 `@key` 로 뜬다(2026-09-15 브레인 ①)."""
|
||||
bad = []
|
||||
for entry in labels["files"]:
|
||||
for table in entry["tables"]:
|
||||
if table["shape"] != "map":
|
||||
continue
|
||||
first = table["columns"][0] if table["columns"] else None
|
||||
if first is None or first["key"] != ROW_KEY_COLUMN:
|
||||
bad.append(f"{entry['file_id']}::{table['key']} — 줄 이름 열 없음")
|
||||
elif not first["name_ko"].strip():
|
||||
bad.append(f"{entry['file_id']}::{table['key']} — 줄 이름 열에 한글 이름 없음")
|
||||
elif not first.get("is_row_key"):
|
||||
bad.append(f"{entry['file_id']}::{table['key']} — is_row_key 표시 없음")
|
||||
assert not bad, f"줄 이름 열 문제: {bad}"
|
||||
|
||||
|
||||
def test_줄_이름_열은_사전_모양_표에만_있다(labels):
|
||||
stray = [
|
||||
f"{entry['file_id']}::{table['key']}"
|
||||
for entry in labels["files"]
|
||||
for table in entry["tables"]
|
||||
if table["shape"] != "map" and any(c["key"] == ROW_KEY_COLUMN for c in table["columns"])
|
||||
]
|
||||
assert not stray, f"사전 모양이 아닌데 줄 이름 열이 있다: {stray}"
|
||||
|
||||
|
||||
def test_줄_이름_열의_이름이_표마다_제_것이다(labels):
|
||||
"""뜻이 표마다 다르므로 한 이름으로 뭉치면 안 된다 — 같은 이름이 겹치면 살펴볼 것."""
|
||||
names = [
|
||||
column["name_ko"]
|
||||
for entry in labels["files"]
|
||||
for table in entry["tables"]
|
||||
for column in table["columns"]
|
||||
if column["key"] == ROW_KEY_COLUMN
|
||||
]
|
||||
assert names, "줄 이름 열이 하나도 없다"
|
||||
# 「출처 키」만 두 표가 같은 뜻으로 쓴다(출처 사전 둘). 그 밖은 겹치지 않는다.
|
||||
duplicated = {n for n in names if names.count(n) > 1}
|
||||
assert duplicated <= {"출처 키"}, f"줄 이름이 겹친다: {sorted(duplicated)}"
|
||||
|
||||
|
||||
def test_값_묶음마다_값이냐_설명이냐가_적혀_있다(labels):
|
||||
known = set(labels["value_roles"])
|
||||
bad = []
|
||||
for entry in labels["files"]:
|
||||
for group in entry["value_groups"]:
|
||||
if group.get("role") not in known:
|
||||
bad.append(f"{entry['file_id']}::{group['key']} — {group.get('role')!r}")
|
||||
assert not bad, f"값·설명이 안 갈린 값 묶음: {bad}"
|
||||
|
||||
|
||||
def test_값_갈래_잣대가_이름표에_적혀_있다(labels):
|
||||
rule = labels["policy"]["value_role_rule"]
|
||||
assert "금액이 움직이나" in rule
|
||||
assert labels["policy"]["row_key_column"]
|
||||
|
||||
|
||||
def test_한_값짜리_요율은_값으로_선다(labels):
|
||||
"""산재 3.56 % 처럼 원가에 그대로 드는 요율이 설명으로 숨으면 트리에서 사라진다."""
|
||||
rates = next(f for f in labels["files"] if f["file_id"] == "rates")
|
||||
by_key = {g["key"]: g["role"] for g in rates["value_groups"]}
|
||||
for key in (
|
||||
"variables/rate_sanjae",
|
||||
"variables/rate_health",
|
||||
"variables/rate_care",
|
||||
"variables/rate_vat",
|
||||
"variables/rate_retirement_mutual_aid",
|
||||
"variables/rate_wage_claim_contribution",
|
||||
"variables/rate_asbestos_contribution",
|
||||
):
|
||||
assert by_key.get(key) == "value", f"{key} 가 값으로 안 섰다"
|
||||
assert by_key.get("processing_rules") == "note"
|
||||
|
||||
|
||||
def test_세어_둔_갈래_수가_실제와_같다(labels):
|
||||
counted: dict[str, int] = {}
|
||||
for entry in labels["files"]:
|
||||
for group in entry["value_groups"]:
|
||||
counted[group["role"]] = counted.get(group["role"], 0) + 1
|
||||
assert counted == labels["counts"]["value_groups_by_role"]
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user