Files
Aislo/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Keys.py
T

345 lines
15 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)
_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)