Files
Aislo/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Keys.py
T
eomsangdonandClaude Opus 5 eca9c37bbd feat(b08): 갈래에도 불변 열쇠 — FW-00105#01 꼴 · 글자는 보일 이름 칸으로
갈래가 공종 열쇠와 똑같은 병을 앓고 있었음 — 줄 열쇠에 갈래 **글자**가 그대로 들어가
`FW-00105#1.1∼1.5` 였고, 원문이 물결표를 둘(∼ 26 · ~ 8)로 쓰고 자간 공백만 다른 짝이
6쌍이라 **이름 한 번 다듬으면 열쇠가 갈렸음**(메인의 「띄어진 이름 175줄 다듬기」가 곧 그 손질).

- 갈래 장부 — `work_item_keys.json` 의 `variants`. 자리는 **공종 열쇠 아래**(목차 코드 아래가 아님)
  · 산림 66 공종 · 갈래 335 · 맞대는 꼴은 공백을 다 떼고 물결표를 하나로
  · 장부에 없는 갈래는 새 번호 · 사라진 번호는 다시 쓰지 않음(옛 일위대가가 가리키던 자리)
- 이름 칸 둘 — `name`(원문 그대로) · `name_clean`(자간 한 칸 · 물결표 하나) · 14줄이 다듬어짐
- Z01 줄 열쇠 `FW-00105#01` · 칸 `variant`(원문)·`variant_clean`(다듬은 것)
- 이름표 — 장부 `variants` 표 · 목록 열 `variant_clean` · 마스터 `variants` 칸
- 시험 — 갈래 열쇠 누락·중복 0 · 번호가 1부터 빠짐없음 ·
  **원문을 다듬어도 같은 번호가 나옴**(물결표 바꿔 넣고 자간 정리해도 새 발급 0)

함께 — 건설 총칙 뿌리 확정(안티그래비티 조사 · 브레인): 공통부문 제8장 건설기계(CP-01-08)를
총칙에 더함(산림 FP-02 와 짝) · 유지관리 제1장 「공 통」은 공종으로 둠
⇒ 건설 줄 구실 공종 923 · 묶는 마디 258 · 총칙 112 · 빈 잎 74

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
2026-09-17 17:57:05 +09:00

313 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""공종 마스터의 **불변 열쇠**(`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)
def variant_display(text: str) -> str:
"""다듬은 갈래 이름 — 자간 공백을 한 칸으로, 물결표를 하나로. 원문은 따로 남긴다."""
spaced = " ".join(str(text or "").split())
return "".join(_TILDES.get(ch, ch) for ch in spaced)
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)