Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 — 공종 축 불변 열쇠 받음 · 이름표 갈래 이름 한 자리 충돌(「공종 축」 취하고 설명은 양쪽 합침) · 산림 줄 열쇠 판정을 work_item_key 로 · 시험이 FW- 열쇠를 봄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-17 12:02:08 +09:00
co-authored by Claude Opus 5
9 changed files with 7927 additions and 498 deletions
@@ -38,6 +38,8 @@ from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import judged_form
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json"
OUT_DIR = ROOT / "resources" / "data_work_item_master"
#: 불변 열쇠 장부 — **판이 붙지 않는다.** 판마다 새로 짓는 산출물이 아니라 대대로 잇는 장부다.
KEY_REGISTRY = OUT_DIR / "work_item_keys.json"
SCHEMA_VERSION = "1.0"
CODE_PREFIX = "FP" # Forest Pumsem — 공종코드 접두. 품셈 절 번호를 그대로 싣는다.
@@ -583,7 +585,12 @@ def basis_quantity_is_grouped(quantity: float | None) -> bool:
return quantity is not None and abs(quantity - 1.0) > 1e-9
def build() -> dict[str, Any]:
def build() -> tuple[
dict[str, Any],
list[dict[str, Any]],
list[dict[str, Any]],
tuple[dict[str, Any], list[dict[str, str]]],
]:
data = json.loads(SOURCE.read_text(encoding="utf-8"))
# 사람 판정 선언(표 형태·부모 모양)은 절 번호로 적혀 판에 묶임 — 판이 다르면 멈춤(명세 17장).
from common_util.common_util_pum_edition import require_code_edition
@@ -721,6 +728,22 @@ def build() -> dict[str, Any]:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Variants import attach_variant_keys
attach_variant_keys(nodes, data["effective_date"])
# 불변 열쇠(`FW-00001`) — 목차 번호를 열쇠에서 칸으로 내린다(2026-09-17 브레인 ①).
# ⚠ 장부에 없는 목차 코드는 **새 열쇠**를 받는다. 짐작으로 옛 열쇠를 물려주지 않는다.
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import (
assign_keys,
decorate,
load_registry,
toc_edition,
)
edition = toc_edition(data.get("sources"), data["effective_date"])
registry = load_registry(KEY_REGISTRY)
newly_issued, toc_duplicates = assign_keys(nodes, registry, edition=edition)
decorate(nodes, edition=edition)
role_counts: dict[str, int] = {}
for node in nodes:
role_counts[node["axis_role"]] = role_counts.get(node["axis_role"], 0) + 1
src_meta = {
"dataset_id": data["dataset_id"],
@@ -735,6 +758,8 @@ def build() -> dict[str, Any]:
"effective_date": data["effective_date"],
# 공종 코드(`FP-*` 절 번호)가 묶인 품셈 판 — 자원 축·인계 줄이 이 값을 싣는다.
"pum_edition": data["effective_date"],
# 목차 번호가 묶인 고시 판. `work_item_code`(=`toc_code`)는 **이 판의 목차 번호**다.
"toc_edition": edition,
"generated_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"dataset_version": src_meta,
"policy": {
@@ -742,6 +767,10 @@ def build() -> dict[str, Any]:
"resource_axis_owner": "B09",
"no_invented_values": True,
"raw_row_preserved": True,
# 줄의 열쇠는 `work_item_key`(`FW-…`)다. `work_item_code`·`toc_number` 는
# **그 판의 목차 번호**라 개정 때 밀린다 — 열쇠로 쓰지 말 것.
"row_key": "work_item_key",
"toc_is_column_not_key": True,
},
"stats": {
"toc_nodes": len(nodes),
@@ -752,18 +781,30 @@ def build() -> dict[str, Any]:
"basis_found": basis_found,
"basis_missing": len(basis_missing),
"basis_grouped": basis_grouped,
# 줄의 구실 — 공종(`work_item`) · 묶는 마디(`group`) · 총칙(`general_provision`) ·
# 표도 아래도 없는 잎(`empty`). 화면·조합은 공종 축만 보면 된다.
"axis_roles": role_counts,
"keys_newly_issued": len(newly_issued),
"toc_duplicate_rows": len(toc_duplicates),
},
# ⚠ 품셈 목차가 **같은 번호를 두 번 적은 자리**. 목차 번호를 열쇠로 쓰면 이 줄들이
# 서로 덮는다 — 열쇠를 따로 두는 까닭의 실물이다.
"toc_duplicate_rows": toc_duplicates,
"orphan_tables": orphans,
"work_items": nodes,
},
undetermined,
basis_missing,
(registry, newly_issued),
)
def main() -> None:
master, undetermined, basis_missing = build()
master, undetermined, basis_missing, (registry, newly_issued) = build()
OUT_DIR.mkdir(parents=True, exist_ok=True)
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import save_registry
save_registry(KEY_REGISTRY, registry)
date = master["effective_date"]
master_path = OUT_DIR / f"work_item_master_{date}.json"
undet_path = OUT_DIR / f"form_undetermined_{date}.json"
@@ -830,6 +871,18 @@ def main() -> None:
print(f"형태 미판정 {s['form_undetermined']}")
print(f"밑수 확보 {s['basis_found']} (묶음 기준 {s['basis_grouped']})")
print(f"밑수 미확보 {s['basis_missing']}{basis_path.name}")
roles = s["axis_roles"]
print(
"줄 구실 공종 {} · 묶는 마디 {} · 총칙 {} · 빈 잎 {}".format(
roles.get("work_item", 0),
roles.get("group", 0),
roles.get("general_provision", 0),
roles.get("empty", 0),
)
)
print(f"열쇠 새로 발급 {len(newly_issued)} (장부 {KEY_REGISTRY.name})")
if s["toc_duplicate_rows"]:
print(f"목차 번호 겹침 {s['toc_duplicate_rows']} (열쇠는 안 겹침)")
print(f"산출 {master_path.relative_to(ROOT)}")
@@ -0,0 +1,205 @@
"""공종 마스터의 **불변 열쇠**(`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 — 산림 공종 열쇠
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() -> 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": KEY_PREFIX,
"key_digits": KEY_DIGITS,
"next_serial": 1,
"editions": {},
"keys": {},
}
def load_registry(path: Path) -> dict[str, Any]:
"""장부를 읽는다. 없으면 빈 장부."""
if not path.exists():
return empty_registry()
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) -> str:
return f"{KEY_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["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) -> 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_PROVISION_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) -> 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)
@@ -65,7 +65,7 @@
},
"work_item": {
"name_ko": "공종 축",
"summary": "**무엇을 하나** — 품셈 목차에서 일위대가가 붙는 잎 공종을 평평하게 편 목록(산림·건설). 읽기만."
"summary": "**무슨 일인가** — 품셈 목차가 세운 공종 나무를 잎으로 평평하게 편 목록(산림·건설). 줄의 열쇠는 `work_item_key`(FW·CW), 목차 번호는 칸이다. 읽기만."
}
},
"merged_tables": [
@@ -1759,11 +1759,11 @@
{
"table": "work_item_forest",
"name_ko": "산림품셈 기준",
"verdict": "있음(임시) — 지금은 목차 코드 · 불변 열쇠 오면 그 칸",
"verdict": "있음",
"key": [
"work_item_code"
"work_item_key"
],
"checked": "잎 358 줄 공종코드 겹침 0(전수 · `test_z01_work_items`). 목차 번호라 품셈 개정으로 밀림 — 데스크탑 서브가 붙이는 불변 열쇠(FW-)가 오면 서버가 그 칸을 `@id` 로 씀(`Z01_MasterData_WorkItems.KEY_FIELD`)."
"checked": "잎 358 줄 불변 열쇠(FW-) 겹침 0(전수 · `test_z01_work_items`). 목차 코드(FP-)는 칸으로만 — 개정 때 밀림."
}
],
"null_is_real": {
@@ -1860,12 +1860,12 @@
"merged_tables": 16,
"merged_columns": 164,
"files": 34,
"tables": 91,
"columns": 462,
"value_groups": 175,
"tables": 92,
"columns": 472,
"value_groups": 176,
"unknown": 0,
"value_groups_by_kind": {
"doc": 109,
"doc": 110,
"value": 66
}
},
@@ -2058,7 +2058,7 @@
"file_id": "work_item_master",
"path": "resources/data_work_item_master/work_item_master_2026-01-01.json",
"name_ko": "공종 마스터",
"summary": "품셈 목차를 공종코드(FP-…) 나무로 세운 벌. 수량 줄이 붙는 자리다.",
"summary": "품셈 목차를 나무로 세운 벌. 줄의 열쇠는 `work_item_key`(FW-…)이고 목차 코드(FP-…)·번호·판은 칸이다. 수량 줄이 붙는 자리다.",
"kind": "logic",
"tables": [
{
@@ -2154,6 +2154,75 @@
"name_ko": "단계 합산 근거",
"unit": "",
"visible": true
},
{
"key": "work_item_key",
"name_ko": "공종 열쇠",
"unit": "",
"visible": true
},
{
"key": "toc_code",
"name_ko": "그 판 목차 코드",
"unit": "",
"visible": true
},
{
"key": "toc_number",
"name_ko": "그 판 목차 번호",
"unit": "",
"visible": true
},
{
"key": "toc_edition",
"name_ko": "목차 판(고시)",
"unit": "",
"visible": false
},
{
"key": "path_name",
"name_ko": "전체 경로 이름",
"unit": "",
"visible": true
},
{
"key": "axis_role",
"name_ko": "줄 구실",
"unit": "",
"visible": true
}
]
},
{
"key": "toc_duplicate_rows",
"name_ko": "목차 번호가 겹친 줄",
"summary": "품셈 목차가 같은 번호를 두 번 적은 자리. 번호를 열쇠로 못 쓰는 실물이다.",
"shape": "list",
"rows": 2,
"columns": [
{
"key": "toc_code",
"name_ko": "목차 코드",
"unit": "",
"visible": true
},
{
"key": "toc_number",
"name_ko": "목차 번호",
"unit": "",
"visible": true
},
{
"key": "name",
"name_ko": "이름",
"unit": "",
"visible": true
},
{
"key": "slot",
"name_ko": "장부 자리",
"unit": "",
"visible": true
}
]
}
@@ -2170,6 +2239,12 @@
"name_ko": "집계",
"summary": "몇 줄이 섰고 몇 줄이 빠졌는지 센 것.",
"kind": "doc"
},
{
"key": "toc_edition",
"name_ko": "목차 판(고시)",
"summary": "목차 번호가 묶인 고시 판. 값이 아니라 **판을 가리키는 표**다.",
"kind": "doc"
}
]
},
@@ -6944,6 +7019,11 @@
"unit": "",
"visible": true
},
"axis_role": {
"name_ko": "줄 구실",
"unit": "",
"visible": true
},
"base_amount_krw": {
"name_ko": "기초액",
"unit": "원",
@@ -7609,6 +7689,11 @@
"unit": "",
"visible": true
},
"path_name": {
"name_ko": "전체 경로 이름",
"unit": "",
"visible": true
},
"payload_density_note": {
"name_ko": "적재 단위중량 비고",
"unit": "",
@@ -7659,6 +7744,11 @@
"unit": "",
"visible": true
},
"pum_tables": {
"name_ko": "딸린 품셈 표",
"unit": "",
"visible": true
},
"pumsem": {
"name_ko": "어느 품셈",
"unit": "",
@@ -7899,6 +7989,11 @@
"unit": "",
"visible": true
},
"step_weight": {
"name_ko": "단계 비중",
"unit": "",
"visible": true
},
"steps": {
"name_ko": "단계 합산 구성",
"unit": "",
@@ -7929,6 +8024,21 @@
"unit": "",
"visible": true
},
"toc_code": {
"name_ko": "그 판 목차 코드",
"unit": "",
"visible": true
},
"toc_edition": {
"name_ko": "목차 판(고시)",
"unit": "",
"visible": true
},
"toc_number": {
"name_ko": "그 판 목차 번호",
"unit": "",
"visible": true
},
"type_id": {
"name_ko": "구조물 종류 키",
"unit": "",
@@ -8029,6 +8139,11 @@
"unit": "",
"visible": true
},
"work_item_key": {
"name_ko": "공종 열쇠",
"unit": "",
"visible": true
},
"work_type": {
"name_ko": "공사 종류",
"unit": "",
@@ -8540,7 +8655,125 @@
"work_item_mapping::variant_contract": {
"name_ko": "갈래 키 계약",
"summary": "갈래 키 문자열을 두 창이 각자 조립하지 않기로 한 약속."
},
"work_item_master::work_item": {
"name_ko": "공종 — 품셈 표가 붙은 줄"
},
"work_item_master::group": {
"name_ko": "묶는 마디 — 표는 없고 아래를 고르는 자리(계산 규칙을 지닌다)"
},
"work_item_master::general_provision": {
"name_ko": "총칙 — 문서 구역이나 값표가 붙어 B09 가 읽는다"
},
"work_item_master::empty": {
"name_ko": "빈 잎 — 표도 아래도 없는 자리"
},
"work_item_master::choose_one": {
"name_ko": "아래 하나를 고름"
},
"work_item_master::sum_steps": {
"name_ko": "아래를 단계로 다 더함"
}
},
"unknown": []
"unknown": [],
"work_item_axis": {
"note": "공종 축(불변 열쇠 FW·CW) 화면 이름표. 기초데이터 열넷 셈(`merged_tables`)에 섞이지 않게 여기 따로 둔다. Z01 이 이 구획을 읽어 상자 이름·열 이름을 뜨면 된다.",
"group": "work_item",
"kinds": [
{
"key": "work_item_forest",
"name_ko": "산림 공종",
"summary": "산림사업 표준품셈 목차가 세운 공종 나무.",
"source": "resources/data_work_item_master/work_item_master_2026-01-01.json",
"key_prefix": "FW"
},
{
"key": "work_item_const",
"name_ko": "건설 공종",
"summary": "건설공사 표준품셈 목차가 세울 공종 나무(아직 안 뽑음).",
"source": "resources/data_cost_input_value/pum_const_2026.json",
"key_prefix": "CW"
}
],
"columns": [
{
"key": "work_item_key",
"name_ko": "공종 열쇠",
"unit": "",
"visible": true
},
{
"key": "path_name",
"name_ko": "전체 경로 이름",
"unit": "",
"visible": true
},
{
"key": "name",
"name_ko": "이름",
"unit": "",
"visible": true
},
{
"key": "toc_code",
"name_ko": "그 판 목차 코드",
"unit": "",
"visible": true
},
{
"key": "toc_number",
"name_ko": "그 판 목차 번호",
"unit": "",
"visible": true
},
{
"key": "toc_edition",
"name_ko": "목차 판(고시)",
"unit": "",
"visible": false
},
{
"key": "level",
"name_ko": "단계",
"unit": "",
"visible": true
},
{
"key": "axis_role",
"name_ko": "줄 구실",
"unit": "",
"visible": true
},
{
"key": "pum_tables",
"name_ko": "딸린 품셈 표",
"unit": "",
"visible": true
},
{
"key": "rule",
"name_ko": "계산 규칙",
"unit": "",
"visible": true
},
{
"key": "step_weight",
"name_ko": "단계 비중",
"unit": "",
"visible": true
}
],
"values": {
"axis_role": {
"work_item": "공종 — 품셈 표가 붙은 줄",
"group": "묶는 마디 — 표는 없고 아래를 고르는 자리(계산 규칙을 지닌다)",
"general_provision": "총칙 — 문서 구역이나 값표가 붙어 B09 가 읽는다",
"empty": "빈 잎 — 표도 아래도 없는 자리"
},
"rule": {
"choose_one": "아래 하나를 고름",
"sum_steps": "아래를 단계로 다 더함"
}
}
}
}
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-15T05:49:19+09:00",
"generated_at": "2026-09-17T10:24:52+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "2a0049e73fe48e44c79dda354ae13cf850a61227acdc22645cb94d52e89bbb2b",
"size_bytes": 839589
"sha256": "6aad7aa1e526d306e7b7ab70229e364d24b0d236671e4e7b3ad1dac5a57e4b3b",
"size_bytes": 954644
},
{
"file": "form_undetermined_2026-01-01.json",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,214 @@
"""공종 축 불변 열쇠 검사 — 2026-09-17 브레인 ①.
여기서 못박는 것은 하나다. **금액이 움직이면 안 된다.**
열쇠를 얹는 일은 값을 바꾸는 일이 아니므로, 새 칸을 떼어내면 옛 벌과 **한 글자도 달라선 안 된다.**
그 자리를 지키는 것이 `test_옛_값이_한_글자도_안_바뀜` 의 지문(sha256)이다.
나머지는 열쇠의 약속 — 안 겹침·꼴·두 번 지어도 같음 · **짐작으로 안 이음** ·
계층(`parent_mode`)이 그대로 있음 · 총칙 2장을 지우지 않았음(B09 연료·손료가 읽는다).
"""
from __future__ import annotations
import copy
import hashlib
import json
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import OUT_DIR # noqa: E402
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import ( # noqa: E402
assign_keys,
axis_role,
empty_registry,
format_key,
path_names,
toc_edition,
toc_slot,
)
MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
#: 열쇠를 얹기 **전** 벌(`work_items`)의 지문. 2026-09-17 데스크탑_서브가 뜬 값.
#: ⚠ 이 값이 흔들리면 열쇠 작업이 값을 건드렸다는 뜻이다 — 지문을 고치지 말고 원인을 찾을 것.
BASELINE_WORK_ITEMS_SHA = "f8b8fba1ab5e6d1d5611b99148eecbed1eaf049c42d7154820f0d9a45e20ffea"
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
ADDED_FIELDS = (
"work_item_key",
"toc_code",
"toc_number",
"toc_edition",
"path_name",
"axis_role",
)
@pytest.fixture(scope="module")
def master() -> dict:
return json.loads(MASTER.read_text(encoding="utf-8"))
def _fingerprint(items: list[dict]) -> str:
stripped = [{k: v for k, v in it.items() if k not in ADDED_FIELDS} for it in items]
blob = json.dumps(stripped, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode()).hexdigest()
def test_옛_값이_한_글자도_안_바뀜(master: dict) -> None:
"""금액을 낳는 값(표·밑수·형태)이 그대로인가 — 새 칸만 떼고 지문을 맞댄다."""
assert _fingerprint(master["work_items"]) == BASELINE_WORK_ITEMS_SHA
def test_모든_줄에_열쇠가_있고_안_겹침(master: dict) -> None:
items = master["work_items"]
keys = [it["work_item_key"] for it in items]
assert all(re.fullmatch(r"FW-\d{5}", k) for k in keys)
assert len(set(keys)) == len(items)
def test_목차_번호는_칸으로만_남음(master: dict) -> None:
"""`FP-…` 는 지우지 않고 「그 판의 목차 번호」 칸으로 남았는가 — 164 파일이 아직 쓴다."""
assert master["policy"]["row_key"] == "work_item_key"
assert master["policy"]["toc_is_column_not_key"] is True
assert master["toc_edition"] == "산림청고시제2025-82호"
for it in master["work_items"]:
assert it["toc_code"] == it["work_item_code"]
assert it["toc_number"] == it["number"]
assert it["toc_edition"] == master["toc_edition"]
def test_장부가_있으면_같은_열쇠가_다시_나옴(master: dict) -> None:
"""두 번 지어도 같은가 — 장부를 그대로 두고 다시 발급해 본다."""
registry = json.loads((OUT_DIR / "work_item_keys.json").read_text(encoding="utf-8"))
before = copy.deepcopy(registry)
nodes = [
{
"work_item_code": it["work_item_code"],
"number": it["number"],
"name": it["name"],
"sort_order": it["sort_order"],
}
for it in master["work_items"]
]
newly, _ = assign_keys(nodes, registry, edition=master["toc_edition"])
assert newly == [] # 다시 발급하지 않는다
assert registry["next_serial"] == before["next_serial"]
assert [n["work_item_key"] for n in nodes] == [
it["work_item_key"] for it in master["work_items"]
]
def test_장부에_없는_코드는_새_열쇠를_받음() -> None:
"""⚠ 짐작으로 안 이음 — 이름이 같아도 옛 열쇠를 물려주지 않고 새로 내고 목록에 싣는다."""
registry = empty_registry()
old = [{"work_item_code": "FP-09-03", "number": "9-3", "name": "토사깍기", "sort_order": 256}]
assign_keys(old, registry, edition="산림청고시제2025-82호")
# 새 판에서 번호가 밀린 같은 이름의 절
new = [{"work_item_code": "FP-09-04", "number": "9-4", "name": "토사깍기", "sort_order": 256}]
newly, _ = assign_keys(new, registry, edition="산림청고시제9999-99호")
assert new[0]["work_item_key"] != old[0]["work_item_key"]
assert [n["work_item_key"] for n in newly] == [new[0]["work_item_key"]]
def test_열쇠_차례는_목차_차례() -> None:
registry = empty_registry()
nodes = [
{"work_item_code": "FP-02", "number": "2", "name": "", "sort_order": 512},
{"work_item_code": "FP-01", "number": "1", "name": "하나", "sort_order": 256},
]
assign_keys(nodes, registry, edition="e")
assert nodes[1]["work_item_key"] == format_key(1)
assert nodes[0]["work_item_key"] == format_key(2)
def test_목차_번호가_겹친_줄도_열쇠는_따로(master: dict) -> None:
"""⚠ 품셈 목차가 12-17-2·12-24-1 을 두 번 적었음 — 번호를 열쇠로 못 쓰는 실물."""
dups = master["toc_duplicate_rows"]
assert [d["toc_number"] for d in dups] == ["12-17-2", "12-24-1"]
assert [d["slot"] for d in dups] == ["FP-12-17-02#2", "FP-12-24-01#2"]
for number in ("12-17-2", "12-24-1"):
rows = [it for it in master["work_items"] if it["number"] == number]
assert len(rows) == 2
assert len({r["work_item_key"] for r in rows}) == 2
assert len({r["path_name"] for r in rows}) == 2
def test_슬롯_이름() -> None:
assert toc_slot("FP-12-17-02", 1) == "FP-12-17-02"
assert toc_slot("FP-12-17-02", 2) == "FP-12-17-02#2"
def test_잎_경로_이름이_안_겹침(master: dict) -> None:
"""「인력」 3곳 · 「수확」 6곳처럼 겹치던 잎 이름이 경로 이름으로 갈리는가."""
items = master["work_items"]
has_kid = {it.get("parent_code") for it in items}
leaves = [it for it in items if it["work_item_code"] not in has_kid and it["tables"]]
names = [it["name"] for it in leaves]
paths = [it["path_name"] for it in leaves]
assert len(set(names)) < len(names) # 이름만으로는 겹친다 — 경로가 필요한 까닭
assert len(set(paths)) == len(paths)
사람 = next(it for it in items if it["work_item_code"] == "FP-09-03-01")
assert 사람["path_name"] == "토공 토사깍기 인력"
def test_계층과_계산_규칙은_그대로(master: dict) -> None:
"""`parent_mode` 가 계산 규칙을 지닌다 — 묶는 마디를 지우지 않았는가."""
items = master["work_items"]
modes = [it.get("parent_mode") for it in items]
assert modes.count("choose_one") == 91
assert modes.count("sum_steps") == 3
발파암 = next(it for it in items if it["work_item_code"] == "FP-09-05")
assert [s["code"] for s in 발파암["steps"]] == ["FP-09-05-01", "FP-09-05-02", "FP-09-05-03"]
def test_총칙은_지우지_않고_구실로_가름(master: dict) -> None:
"""⚠ `B09_Estimation_Consumables` 가 총칙 2장 표를 읽는다 — 지우면 연료·손료가 사라진다."""
by_code = {it["work_item_code"]: it for it in master["work_items"]}
for code, table_id in (
("FP-02-01-01", "F0042"), # 체인톱 보통휘발유
("FP-02-02-01", "F0064"), # 체인톱 손료계수
("FP-02-02-06", "F0069"), # 양수기 손료계수
):
node = by_code[code]
assert node["axis_role"] == "general_provision"
assert table_id in [t["pum_table_id"] for t in node["tables"]]
roles = master["stats"]["axis_roles"]
assert sum(roles.values()) == len(master["work_items"])
assert roles["general_provision"] == 78
def test_구실_가름_규칙() -> None:
assert (
axis_role({"work_item_code": "FP-01-02", "tables": []}, has_children=True)
== "general_provision"
)
assert (
axis_role({"work_item_code": "FP-09-03-01", "tables": [{}]}, has_children=False)
== "work_item"
)
assert axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True) == "group"
assert axis_role({"work_item_code": "FP-09-01", "tables": []}, has_children=False) == "empty"
def test_경로_이름_잇기() -> None:
nodes = [
{"work_item_code": "A", "parent_code": None, "name": "토공"},
{"work_item_code": "A-1", "parent_code": "A", "name": "토사깍기"},
{"work_item_code": "A-1-1", "parent_code": "A-1", "name": "인력"},
]
assert path_names(nodes)[2] == "토공 토사깍기 인력"
def test_고시_번호를_원문_경로에서_읽음() -> None:
sources = [
{"path": "resources/knowledge/original/…/(산림청고시 제2025-82호) 산림사업 표준품셈.md"}
]
assert toc_edition(sources, "2026-01-01") == "산림청고시제2025-82호"
assert toc_edition([], "2026-01-01") == "2026-01-01"
+3 -2
View File
@@ -1,7 +1,7 @@
"""Z01 마스터 데이터 — 읽기 API(갈래 → 파일 → 표 · 표 줄 쪽 나누기·검색) · 2026-09-15 브레인 새 판.
약속(브레인): 파일 목록·갈래·한글 이름·단위·보임은 **이름표 파일**(`resources/data_master_labels/`)에서만 ·
표 = 화면이 표로 그릴 마디(이름표 91 이 정본) · ⭐ 트리 표 id 와 이름표 표 id 를 **양쪽으로** 세어 둘 다 0 ·
표 = 화면이 표로 그릴 마디(이름표 92 이 정본) · ⭐ 트리 표 id 와 이름표 표 id 를 **양쪽으로** 세어 둘 다 0 ·
열 이름 찾는 차례 「파일id/열key」 → 「열key」 → 영문 key · 줄 한 줄 = {열key: 값} · file·table 은 트리의 id.
"""
@@ -83,7 +83,8 @@ def test_트리_표_id_와_이름표_표_id_는_양쪽으로_같음(client: Test
label_only += [f"{entry['file_id']}::{i}" for i in sorted(label_ids - tree_ids)]
assert not_labelled == [], not_labelled
assert label_only == [], label_only
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 91
# 2026-09-17 91 → 92 — 공종 마스터가 `toc_duplicate_rows`(목차 번호가 겹친 줄) 를 새로 냄.
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 92
for entry in LABELS["files"]: # 이름이 실제로 붙음 · 줄 수도 이름표 셈과 같음
named = {t["key"]: t for t in entry["tables"]}
for t in files[entry["file_id"]]:
+2 -1
View File
@@ -56,7 +56,8 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non
assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"]
assert by_code["FP-09-05-02"]["rule"] == "sum_steps"
assert by_code["FP-09-05-02"]["step_weight"] == "0.9"
assert any("불변 열쇠" in line for line in table["notice"]) # FW- 오기 전 — 드러냄
assert all(r["@id"].startswith("FW-") for r in rows) # 불변 열쇠(데스크탑 서브 7dd2c515)
assert not any("불변 열쇠" in line for line in table["notice"])
found = _get(client, "work_item_forest", q="발파암 ")
assert found["total"] == 3