"""공종 마스터의 **불변 열쇠**(`FW-00001`) — 장부 읽기·발급·칸 얹기 (2026-09-17 브레인 ①). 왜 필요한가 지금 공종의 열쇠는 품셈 목차 번호(`FP-09-03-02`)다. 품셈이 개정돼 절이 지워지거나 번호가 밀리면 **일위대가·조합이 조용히 딴 줄을 가리킨다.** 그래서 번호와 무관한 일련번호를 얹고, 목차 번호·이름·판은 **칸으로 내린다.** 한 번 준 열쇠는 영원히 그 공종 것 그 약속을 지키는 자리가 **장부**(`resources/data_work_item_master/work_item_keys.json`)다. 판(고시 번호)마다 「그 판의 목차 코드 → 열쇠」를 적어 둔다. 장부에 있으면 그 열쇠를 다시 쓰고, 없으면 **새 열쇠를 내고 `newly_issued` 로 뽑아 사람이 보게 한다.** ⚠ 짐작으로 잇지 않는다 새 판에서 이름이 비슷하다고 옛 열쇠를 물려주지 않는다. 그건 **틀리면 금액이 조용히 바뀌는** 종류의 추측이다. 잇는 것은 장부에 손으로 적을 때만 — 그때까지는 새 열쇠 + 목록이다. """ from __future__ import annotations import json import re from datetime import date from pathlib import Path from typing import Any KEY_PREFIX = ( "FW" # Forest Work item — 산림 공종 열쇠 · 건설은 장부를 따로 두고 `CW`(장부 `key_prefix`) ) KEY_DIGITS = 5 PATH_SEP = " › " #: 줄 구실 잣대는 **자료에 둔다** — 총칙 범위는 기관이 정하는 것이라 코드에 박으면 관리자가 못 고친다. AXIS_POLICY = ( Path(__file__).resolve().parent.parent / "resources" / "data_work_item_master" / "axis_policy.json" ) def load_policy(axis: str, path: Path = AXIS_POLICY) -> dict[str, Any]: """`axis_policy.json` 의 한 축(`forest`·`const`). 없는 축을 부르면 멈춘다.""" data = json.loads(path.read_text(encoding="utf-8")) try: return data["axes"][axis] except KeyError: raise ValueError(f"공종 축 잣대에 `{axis}` 가 없다 — {path}") from None def general_provision_roots(axis: str = "forest", path: Path = AXIS_POLICY) -> tuple[str, ...]: """총칙 뿌리 — 값표는 붙어 있으나 공종이 아닌 장. ⚠ 지우지 않고 구실로만 가른다.""" return tuple(load_policy(axis, path).get("general_provision_roots") or ()) #: 원문 경로에 박힌 고시 번호 — 「(산림청고시 제2025-82호)」. _EDITION_RE = re.compile(r"([가-힣]*고시\s*제\s*\d{4}-\d+\s*호)") def toc_edition(sources: list[dict[str, Any]] | None, fallback: str) -> str: """원문 경로에서 고시 번호를 읽는다. 못 읽으면 `fallback`(시행일). ⚠ 번호를 코드에 박지 않는다 — 판이 바뀌면 원문 경로가 먼저 바뀐다. """ for entry in sources or []: if m := _EDITION_RE.search(str(entry.get("path") or "")): return re.sub(r"\s+", "", m.group(1)) return fallback def empty_registry(prefix: str = KEY_PREFIX) -> dict[str, Any]: """빈 장부. 첫 판을 지을 때 이 꼴로 시작한다.""" return { "schema_version": "1.0", "dataset_id": "work_item_keys", "note": ( "공종의 **불변 열쇠** 장부. 판이 바뀌어도 이 열쇠는 그 공종 것이다. " "판마다 「그 판의 목차 코드 → 열쇠」를 적는다." ), "policy": { "key_is_permanent": True, "no_guessed_relink": True, "note": ( "새 판의 목차 코드가 장부에 없으면 **새 열쇠**를 낸다. 이름이 비슷하다고 옛 열쇠를 " "물려주지 않는다 — 잇는 것은 이 파일에 손으로 적을 때만." ), }, "key_prefix": prefix, "key_digits": KEY_DIGITS, "next_serial": 1, "editions": {}, "keys": {}, } def load_registry(path: Path, prefix: str = KEY_PREFIX) -> dict[str, Any]: """장부를 읽는다. 없으면 빈 장부.""" if not path.exists(): return empty_registry(prefix) data = json.loads(path.read_text(encoding="utf-8")) for field in ("next_serial", "editions", "keys"): if field not in data: raise ValueError(f"열쇠 장부에 `{field}` 가 없다 — {path}") return data def save_registry(path: Path, registry: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(registry, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") def format_key(serial: int, prefix: str = KEY_PREFIX) -> str: return f"{prefix}-{serial:0{KEY_DIGITS}d}" def toc_slot(code: str, ordinal: int) -> str: """장부가 쓰는 자리 이름. 첫 줄은 코드 그대로, 겹친 둘째부터 `#2` 를 붙인다. ⚠ 품셈 목차가 **같은 번호를 두 번 적은 자리**가 있다(12-17-2 · 12-24-1). 코드만으로는 줄이 안 갈린다 — 목차 번호가 열쇠로 못 쓰이는 까닭 그 자체다. """ return code if ordinal == 1 else f"{code}#{ordinal}" def assign_keys( nodes: list[dict[str, Any]], registry: dict[str, Any], *, edition: str, issued_on: str | None = None, ) -> tuple[list[dict[str, str]], list[dict[str, Any]]]: """각 줄에 `work_item_key` 를 박고 `(새로 낸 것, 겹친 목차 번호)` 를 돌려준다. 차례는 목차 차례(`sort_order`)다 — 같은 입력이면 늘 같은 열쇠가 나온다. ⚠ 줄마다 제 열쇠다. 목차 번호가 겹쳐도 열쇠는 **안 겹친다.** """ book = registry["editions"].setdefault(edition, {}) issued_on = issued_on or date.today().isoformat() newly: list[dict[str, str]] = [] duplicates: list[dict[str, Any]] = [] seen: dict[str, int] = {} for node in sorted(nodes, key=lambda n: n["sort_order"]): code = node["work_item_code"] ordinal = seen[code] = seen.get(code, 0) + 1 slot = toc_slot(code, ordinal) if ordinal > 1: duplicates.append( {"toc_code": code, "toc_number": node["number"], "name": node["name"], "slot": slot} ) key = book.get(slot) if key is None: key = format_key(registry["next_serial"], registry.get("key_prefix", KEY_PREFIX)) registry["next_serial"] += 1 book[slot] = key registry["keys"][key] = { "issued_edition": edition, "issued_on": issued_on, "first_toc_code": code, "first_toc_number": node["number"], "first_name": node["name"], } newly.append({"work_item_key": key, "toc_slot": slot, "name": node["name"]}) node["work_item_key"] = key return newly, duplicates def path_names(nodes: list[dict[str, Any]]) -> list[str]: """`["토공 › 토사깍기 › 인력", …]` — `nodes` 차례에 맞춘 전체 경로 이름. 잎 이름이 겹쳐도(「인력」 3곳 · 「수확」 6곳) 경로로 갈린다. ⚠ 줄마다 따로 짓는다 — 목차 번호가 겹친 줄이 있어 코드로 묶으면 한쪽이 덮인다. 윗줄 찾기는 코드로 한다(겹친 코드는 다 잎이라 윗줄 자리에 안 선다). """ by_code = {n["work_item_code"]: n for n in nodes} cache: dict[str, str] = {} def upward(code: str, seen: frozenset[str] = frozenset()) -> str: if code in cache: return cache[code] node = by_code[code] parent = node.get("parent_code") name = str(node.get("name") or "") if parent and parent in by_code and parent not in seen: name = upward(parent, seen | {code}) + PATH_SEP + name cache[code] = name return name out: list[str] = [] for node in nodes: parent = node.get("parent_code") name = str(node.get("name") or "") if parent and parent in by_code: name = upward(parent) + PATH_SEP + name out.append(name) return out def axis_role( node: dict[str, Any], *, has_children: bool, roots: tuple[str, ...] | None = None ) -> str: """이 줄이 공종인가 문서 구역인가. 차례가 뜻을 가진다(총칙 먼저). `work_item` 품셈 표가 붙은 줄 — 값이 있는 자리 `group` 표는 없고 아래를 묶는 마디 — `parent_mode` 가 계산 규칙을 지닌다(지우면 안 된다) `general_provision` 총칙 — 문서 구역이나 **값표가 붙어 있어 B09 가 읽는다** `empty` 표도 아래도 없는 잎 — 원문에 표가 안 붙은 자리 ⚠ **어느 구실이든 지우지 않는다.** 가름은 화면·조합이 무엇을 볼지 고르는 것뿐이다. """ if roots is None: roots = general_provision_roots() code = node["work_item_code"] if any(code == root or code.startswith(root + "-") for root in roots): return "general_provision" if node.get("tables"): return "work_item" return "group" if has_children else "empty" def decorate( nodes: list[dict[str, Any]], *, edition: str, roots: tuple[str, ...] | None = None ) -> None: """각 줄에 목차 칸·경로 이름·구실을 얹는다. 열쇠는 `assign_keys` 가 이미 박았다. 기존 칸은 손대지 않는다 — 금액을 낳는 값은 그대로다. """ if roots is None: roots = general_provision_roots() paths = path_names(nodes) has_kid = {n.get("parent_code") for n in nodes} for node, path in zip(nodes, paths): code = node["work_item_code"] node["toc_code"] = code node["toc_number"] = node["number"] node["toc_edition"] = edition node["path_name"] = path node["axis_role"] = axis_role(node, has_children=code in has_kid, roots=roots)