"""별칭표 한 벌 — 우리 이름 ↔ 품셈·실무 이름 (2026-09-13 축 C 명세 5장·17장). 정본 파일 `resources/data_aliases/aliases_<판>.json`. **B08·B09 는 이 파일만 읽는다** — 매핑표· 단가 코드·인계본에 따로 두었더니 세 벌이 서로 달라졌다(리핑암 → 파쇄암·암절취 / 파쇄암만 / 안 읽음). 한 줄 = `{axis, from, to, scope, pum_edition, basis}` axis `resource`(자원 이름 → 카탈로그 코드) · `variant`(우리 갈래 이름 → 품셈 갈래 이름) scope 이 별칭이 **참인 `FP-*` 범위** — 없으면 오류. 별칭은 표 범위에서만 참이다 (「리핑암 = 파쇄암」은 10-11 f 표 안에서만 — 발파암도 캐고 나면 파쇄암 상태다) pum_edition scope 가 묶인 품셈 판 — 판이 다르면 쓰지 않는다(`FP-*` 는 판 안에서만 유일) ⚠ **별칭 ≠ 대체** — 대응이 아예 없어 갈음하는 것은 여기 넣지 않는다(설계가 고른다). ⚠ 같은 이름이 **겹친 범위**에서 두 곳으로 가면 **읽을 때 오류** — 조용히 첫째를 고르지 않는다. """ from __future__ import annotations import json import re from functools import lru_cache from pathlib import Path from typing import Any ALIAS_FILE = ( Path(__file__).resolve().parents[1] / "resources" / "data_aliases" / "aliases_2026-01-01.json" ) AXES = frozenset({"resource", "variant"}) _FIELDS = ("axis", "from", "to", "scope", "pum_edition") class AliasError(ValueError): """별칭표가 규약을 어겼다 — 조용히 넘기지 않는다.""" def _key(text: str) -> str: """이름 비교 키 — 내부 공백만 지운다(「화 약 공」 = 「화약공」). 다른 글자는 안 건드린다.""" return re.sub(r"\s+", "", str(text or "")) def in_scope(work_item_code: str, scope: str) -> bool: """`FP-*` 범위 — 그 코드 자신이거나 그 아래. ⚠ `AX`·`AR` 코드에는 계층이 없어 쓰지 않는다.""" return work_item_code == scope or work_item_code.startswith(scope + "-") def parse_aliases(rows: list[dict[str, Any]]) -> list[dict[str, str]]: """줄을 검사해 돌려준다. 칸 빠짐 · 모르는 축 · 겹친 범위의 두 대상은 오류.""" aliases: list[dict[str, str]] = [] for row in rows: picked = {key: str(row.get(key) or "").strip() for key in _FIELDS} if not all(picked.values()): raise AliasError(f"별칭 줄에 {'·'.join(_FIELDS)} 가 다 있어야 합니다: {row}") if picked["axis"] not in AXES: raise AliasError(f"모르는 별칭 축입니다: {picked['axis']}") picked["basis"] = str(row.get("basis") or "") aliases.append(picked) for index, left in enumerate(aliases): for right in aliases[index + 1 :]: if ( left["axis"] == right["axis"] and _key(left["from"]) == _key(right["from"]) and left["to"] != right["to"] and left["pum_edition"] == right["pum_edition"] and ( in_scope(left["scope"], right["scope"]) or in_scope(right["scope"], left["scope"]) ) ): raise AliasError( f"같은 이름 「{left['from']}」이 겹친 범위에서 두 곳으로 갑니다: " f"{left['to']}({left['scope']}) · {right['to']}({right['scope']})" ) return aliases @lru_cache(maxsize=4) def _load(path: str) -> tuple[dict[str, str], ...]: target = Path(path) if not target.is_file(): return () # 파일이 없으면 별칭 없이 돈다 — 못 맞춘 줄로 드러난다 payload = json.loads(target.read_text(encoding="utf-8")) return tuple(parse_aliases(list(payload.get("aliases") or []))) def load_aliases(axis: str, path: Path | None = None) -> list[dict[str, str]]: """그 축의 별칭 줄. 파일은 한 번만 읽는다.""" return [dict(row) for row in _load(str(path or ALIAS_FILE)) if row["axis"] == axis] def alias_target( rows: list[dict[str, str]], name: str, work_item_code: str, edition: str, *, reverse: bool = False, ) -> str | None: """범위·판이 맞는 줄에서 이름을 옮긴다. `reverse` 면 남의 이름 → 우리 이름(양방향 조회).""" source, target = ("to", "from") if reverse else ("from", "to") wanted = _key(name) for row in rows: if ( row["pum_edition"] == edition and _key(row[source]) == wanted and in_scope(work_item_code, row["scope"]) ): return row[target] return None def names_view(rows: list[dict[str, str]]) -> dict[str, dict[str, Any]]: """인계본 모양 `{우리 이름: {names, scopes, basis}}` — B08 `ground_class_aliases` 가 싣는다.""" view: dict[str, dict[str, Any]] = {} for row in rows: entry = view.setdefault(row["from"], {"names": [], "scopes": {}, "basis": ""}) entry["names"].append(row["to"]) entry["scopes"][row["to"]] = row["scope"] entry["basis"] = " · ".join(part for part in (entry["basis"], row["basis"]) if part) return view