Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
345 lines
15 KiB
Python
345 lines
15 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 = " › "
|
||
|
||
#: 줄 구실 잣대는 **자료에 둔다** — 총칙 범위는 기관이 정하는 것이라 코드에 박으면 관리자가 못 고친다.
|
||
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
|
||
|
||
|
||
#: 갈래 열쇠 자릿수 — `FW-00105#01` 의 뒤 두 자리.
|
||
VARIANT_DIGITS = 2
|
||
#: 원문이 섞어 쓰는 물결표 — 맞댈 때 하나로 모은다.
|
||
_TILDES = {"~": "∼", "〜": "∼", "~": "∼"}
|
||
|
||
|
||
def variant_match(text: str) -> str:
|
||
"""갈래를 맞대는 꼴 — **공백을 다 떼고** 물결표를 하나로.
|
||
|
||
⚠ 왜 이렇게까지 지우나 — 원문이 「간 단」·「간 단」처럼 자간을 들쭉날쭉 쓰고, 이름 다듬기가
|
||
앞으로 더 들어올 자리다. 맞대는 꼴이 공백에 걸리면 **다듬는 날 열쇠가 갈린다.**
|
||
(산림 335 갈래로 재 보니 한 공종 안에서 이 꼴이 겹치는 자리는 없다 — 2026-09-17.)
|
||
"""
|
||
squeezed = "".join(str(text or "").split())
|
||
return "".join(_TILDES.get(ch, ch) for ch in squeezed)
|
||
|
||
|
||
_SYLLABLE = re.compile(r"^[가-힣]$")
|
||
_PIECE_GAP = re.compile(r"( [·ㆍ・›] )")
|
||
|
||
|
||
def tidy_spacing(text: str) -> str:
|
||
"""**자간 공백** 다듬기 — 「측 량」→「측량」 · 「평 규 준 틀」→「평규준틀」 · 「굴 삭 기 (무한궤도)」→「굴삭기 (무한궤도)」.
|
||
|
||
한 글자 낱말뿐인 조각이거나, 한 글자 낱말이 **셋 이상** 이어질 때만 붙임 —
|
||
「그 외 자재」·「호박돌 및 야면석」 같은 진짜 띄어쓰기는 그대로(2026-09-17 서브 훑기 175줄 · 브레인).
|
||
⚠ 보일 이름만 — 열쇠는 번호라(공종 `FW-…` · 갈래 `#01`) 다듬어도 안 흔들림.
|
||
"""
|
||
# 가운뎃점·경로 표시 사이가 한 조각 — 「메 붙 임 · 깬 돌 · 뒷길이 25㎝」 의 「깬 돌」
|
||
pieces = _PIECE_GAP.split(" ".join(str(text or "").split()))
|
||
return "".join(piece if n % 2 else _tidy_piece(piece) for n, piece in enumerate(pieces))
|
||
|
||
|
||
def _tidy_piece(text: str) -> str:
|
||
tokens = text.split(" ")
|
||
if len(tokens) >= 2 and all(_SYLLABLE.match(t) for t in tokens):
|
||
return "".join(tokens)
|
||
out: list[str] = []
|
||
run: list[str] = []
|
||
for token in [*tokens, ""]:
|
||
if token and _SYLLABLE.match(token):
|
||
run.append(token)
|
||
continue
|
||
out.extend(["".join(run)] if len(run) >= 3 else run)
|
||
run = []
|
||
if token:
|
||
out.append(token)
|
||
return " ".join(out)
|
||
|
||
|
||
def variant_display(text: str) -> str:
|
||
"""다듬은 갈래 이름 — 자간 공백을 붙이고 물결표를 하나로. 원문은 따로 남긴다."""
|
||
return "".join(_TILDES.get(ch, ch) for ch in tidy_spacing(text))
|
||
|
||
|
||
def format_variant(serial: int) -> str:
|
||
return f"{serial:0{VARIANT_DIGITS}d}"
|
||
|
||
|
||
def assign_variant_keys(
|
||
nodes: list[dict[str, Any]], registry: dict[str, Any], *, issued_on: str | None = None
|
||
) -> list[dict[str, str]]:
|
||
"""갈래마다 **불변 열쇠**(`FW-00105#01`)를 주고 `variants` 칸을 얹는다.
|
||
|
||
공종 열쇠와 같은 병을 갈래가 그대로 앓고 있었다 — 갈래 글자가 열쇠에 들어가 있어
|
||
**원문 자간이나 물결표가 손질되면 열쇠가 갈렸다.** 그래서 장부에 올린다(2026-09-17 브레인).
|
||
|
||
장부 자리는 **공종 열쇠 아래**다(목차 코드 아래가 아니다) — 목차 번호가 밀려도 안 흔들린다.
|
||
⚠ 장부에 없는 갈래는 **새 번호**를 받는다. 비슷하다고 옛 번호를 물려주지 않는다.
|
||
사라진 갈래의 번호는 **비워 둔 채 다시 쓰지 않는다** — 옛 일위대가가 가리키던 자리다.
|
||
"""
|
||
issued_on = issued_on or date.today().isoformat()
|
||
newly: list[dict[str, str]] = []
|
||
for node in sorted(nodes, key=lambda n: n["sort_order"]):
|
||
raw = node.get("variant_keys") or []
|
||
if not raw:
|
||
node["variants"] = []
|
||
continue
|
||
# ⚠ 갈래가 하나도 없으면 장부에 빈 칸을 만들지 않는다 — 건설처럼 아직 갈래가 없는 벌에
|
||
# 빈 `variants` 가 생기면 이름표가 「있는데 이름이 없는 표」로 잡는다.
|
||
book = registry.setdefault("variants", {})
|
||
entry = book.setdefault(node["work_item_key"], {"next_serial": 1, "slots": {}})
|
||
variants = []
|
||
for text in raw:
|
||
slot = variant_match(text)
|
||
serial = entry["slots"].get(slot)
|
||
if serial is None:
|
||
serial = format_variant(entry["next_serial"])
|
||
entry["next_serial"] += 1
|
||
entry["slots"][slot] = serial
|
||
newly.append(
|
||
{
|
||
"variant_key": f"{node['work_item_key']}#{serial}",
|
||
"name": text,
|
||
"issued_on": issued_on,
|
||
}
|
||
)
|
||
variants.append(
|
||
{
|
||
"variant_key": serial,
|
||
"name": text, # 원문 그대로 — 손대지 않는다
|
||
"name_clean": variant_display(text), # 다듬은 것
|
||
}
|
||
)
|
||
node["variants"] = variants
|
||
return newly
|
||
|
||
|
||
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)
|