Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
220 lines
8.7 KiB
Python
220 lines
8.7 KiB
Python
"""공종 마스터의 **불변 열쇠**(`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 = " › "
|
||
|
||
#: 총칙 — 값표는 붙어 있으나 공종이 아닌 장. 뿌리 코드로 가른다.
|
||
GENERAL_PROVISION_ROOTS = ("FP-01", "FP-02")
|
||
|
||
|
||
#: 원문 경로에 박힌 고시 번호 — 「(산림청고시 제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,
|
||
general_roots: tuple[str, ...] = GENERAL_PROVISION_ROOTS,
|
||
) -> str:
|
||
"""이 줄이 공종인가 문서 구역인가.
|
||
|
||
`work_item` 품셈 표가 붙은 줄 — 값이 있는 자리
|
||
`group` 표는 없고 아래를 묶는 마디 — `parent_mode` 가 계산 규칙을 지닌다(지우면 안 된다)
|
||
`general_provision` 총칙(1·2장) — 문서 구역이나 **값표가 붙어 있어 B09 가 읽는다**
|
||
`empty` 표도 아래도 없는 잎 — 원문에 표가 안 붙은 자리
|
||
"""
|
||
code = node["work_item_code"]
|
||
if any(code == root or code.startswith(root + "-") for root in general_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,
|
||
general_roots: tuple[str, ...] = GENERAL_PROVISION_ROOTS,
|
||
) -> None:
|
||
"""각 줄에 목차 칸·경로 이름·구실을 얹는다. 열쇠는 `assign_keys` 가 이미 박았다.
|
||
|
||
기존 칸은 손대지 않는다 — 금액을 낳는 값은 그대로다.
|
||
"""
|
||
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, general_roots=general_roots
|
||
)
|