Files
Aislo/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Keys.py
T

220 lines
8.7 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 = " "
#: 총칙 — 값표는 붙어 있으나 공종이 아닌 장. 뿌리 코드로 가른다.
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
)