feat(b08): 건설 공종 축 뽑기 — 뽑는 스크립트가 산림·건설 둘 다 · 건설 목차는 합본 원문 머리 「목 차」 글줄(쪽 번호 붙은 꼴 가름 · 부문 5 · 장 45 · 1,367줄) · 표는 본문 절 제목으로 붙음(2,192 전부 · 인용 거름) · 열쇠 CW-00001 꼴(장부 따로) · 총칙 표시·갈래 키는 비움 · 표 한 장 읽기를 _Table 로 떼어 두 뽑기가 한 벌 씀(890 → 442줄 · 산림 산출 한 글자도 안 바뀜) · Z01 건설 kind 이름 work_item_const · 이름표 한 벌 · 목차 오기 없음 시험(연혁 붙은 본문 제목 683)(브레인)

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:44:42 +09:00
co-authored by Claude Opus 5
parent 1de5c8ab5b
commit 642a5be937
17 changed files with 204535 additions and 624 deletions
@@ -1421,6 +1421,69 @@
"visible": true
}
]
},
{
"key": "work_item_const",
"name_ko": "건설품셈 기준",
"summary": "건설 품셈 공종 축의 잎 — 일위대가가 붙는 단위. 이름은 부문부터 전체 경로, 부모 마디 계산 규칙은 표 수준 `rules`.",
"sources": [
"work_item_master_const::work_items"
],
"rows_note": "잎 995 줄 · 갈래 키 아직 없음(편 수 = 잎 수)",
"columns": [
{
"key": "work_item_code",
"name_ko": "공종코드",
"unit": "",
"visible": true,
"note": "건설 목차 코드(CP-부문-장-절-항). ⚠ 불변 열쇠 아님 — 개정 때 번호가 밀림. 열쇠는 `@id`(CW-)."
},
{
"key": "number",
"name_ko": "품셈 번호",
"unit": "",
"visible": true
},
{
"key": "name",
"name_ko": "이름",
"unit": "",
"visible": true,
"note": "부문부터 전체 경로(「공통부문 › 토공사 › 굴착 › 굴착(인력/토사)」) — 부문마다 장 번호가 1부터라 부문이 뿌리."
},
{
"key": "variant_keys",
"name_ko": "갈래 키",
"unit": "",
"visible": true
},
{
"key": "pum_tables",
"name_ko": "딸린 품셈 표",
"unit": "",
"visible": true
},
{
"key": "rule",
"name_ko": "계산 규칙",
"unit": "",
"visible": true,
"note": "부모 마디 방식 — `choose_one`(아래 중 하나) · `sum_steps`(아래를 무게대로 더해야 한 단위)."
},
{
"key": "step_weight",
"name_ko": "단계 무게",
"unit": "",
"visible": true,
"note": "`sum_steps` 일 때만 — 건설은 아직 합산형 선언 없음."
},
{
"key": "effective_date",
"name_ko": "적용 시작일",
"unit": "",
"visible": true
}
]
}
],
"machine_cost_chain": {
@@ -1764,6 +1827,15 @@
"work_item_key"
],
"checked": "잎 360 줄 불변 열쇠(FW-) 겹침 0(전수 · `test_z01_work_items`). 목차 코드(FP-)는 칸으로만 — 개정 때 밀림."
},
{
"table": "work_item_const",
"name_ko": "건설품셈 기준",
"verdict": "있음",
"key": [
"work_item_key"
],
"checked": "잎 995 줄 불변 열쇠(CW-) 겹침 0(전수 · `test_work_item_master_const`). 목차 코드(CP-)는 칸으로만 — 개정 때 밀림."
}
],
"null_is_real": {
@@ -1857,8 +1929,8 @@
}
},
"counts": {
"merged_tables": 16,
"merged_columns": 164,
"merged_tables": 17,
"merged_columns": 172,
"files": 34,
"tables": 92,
"columns": 471,
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-17T12:17:18+09:00",
"generated_at": "2026-09-17T12:34:57+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,7 +12,7 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "e58445ea630862cc5391cd6c5d320c76065cdce92abd7e9f3c5dc55b71369273",
"sha256": "9c8da999439a186f17467832fe898d16e2f88172a2b9a2916728cbb350540259",
"size_bytes": 956887
},
{
@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-17T12:34:57+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_const",
"effective_date": "2026-01-01",
"sha256": "dbc46ef1316b83ed710f25bc402dcecc4a979bbfc4a6641a0554252bca326ca7",
"file": "pum_const_2026.json"
},
"files": [
{
"file": "work_item_master_const_2026-01-01.json",
"sha256": "e38814f0ea1e542a44097ff7f706dccec62faa737aa67dea6686ee4395820564",
"size_bytes": 3752147
},
{
"file": "form_undetermined_const_2026-01-01.json",
"sha256": "dbb4f245cb9aee71325ab73c1325ee5f06ff19e0e7527de2053d08a7eb16282e",
"size_bytes": 486761
},
{
"file": "basis_missing_const_2026-01-01.json",
"sha256": "0ae14342e37ba5f394c36cc4dd8cea1b505b13d6520e0a12979c21676d6ffcdb",
"size_bytes": 74228
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@
"effective_date": "2026-01-01",
"pum_edition": "2026-01-01",
"toc_edition": "산림청고시제2025-82호",
"generated_at": "2026-09-17T12:17:18+09:00",
"generated_at": "2026-09-17T12:34:57+09:00",
"dataset_version": {
"dataset_id": "pum_forest",
"effective_date": "2026-01-01",
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -470,7 +470,8 @@ MERGED_KEYS = (
"stone_kind",
"masonry_class",
"machine_productivity",
"work_item_forest", # 2026-09-17 공종 축 산림(건설은 자료가 오면)
"work_item_forest", # 2026-09-17 공종 축 산림
"work_item_const", # 2026-09-17 공종 축 건설
)
@@ -0,0 +1,142 @@
"""건설 공종 축(`CW-00001`) 뽑기 — 2026-09-17 브레인 8-2.
못박는 것
① 목차 글줄 가르기 — 쪽 번호가 다음 번호에 붙은 꼴(「131-2-6」 = 13쪽 + 1-2-6) · 부문마다 장 번호 1부터
② 표는 **본문 절 제목**으로 붙음 — 인용(「3-2-4 터파기(기계)’를 참고하여」)은 제목이 아님
③ 목차 오기 없음 — 연혁 표시(「('20년 보완)」)가 붙은 본문 제목이 모두 목차 번호·이름과 같음
(산림에서 목차가 번호를 잘못 적어 표가 딴 줄에 붙은 병 · 건설도 같은 함정)
④ 못 붙인 표는 목록으로 · 열쇠 CW- 안 겹침 · 다시 뽑아도 같은 열쇠
⑤ 산출 파일 = 지금 코드로 뽑은 것(산림도 같이 — 두 뽑기가 표 읽기 한 벌을 나눠 씀 · 금액 불변)
"""
from __future__ import annotations
import json
import re
import pytest
from B08_Quantity import B08_Quantity_Build_WorkItemMaster as forest
from B08_Quantity import B08_Quantity_Build_WorkItemMaster_Const as const
OUT = forest.OUT_DIR
CONST_MASTER = OUT / "work_item_master_const_2026-01-01.json"
@pytest.fixture(scope="module")
def built() -> tuple:
return const.build_const()
@pytest.fixture(scope="module")
def master() -> dict:
return json.loads(CONST_MASTER.read_text(encoding="utf-8"))
def _same_except_time(fresh: dict, saved: dict) -> None:
fresh = json.loads(json.dumps(fresh, ensure_ascii=False))
for doc in (fresh, saved):
doc.pop("generated_at")
doc["stats"].pop("keys_newly_issued") # 첫 뽑기만 발급 · 다시 뽑으면 0
assert fresh == saved
def test_목차_글줄_가르기_쪽_번호가_붙은_꼴() -> None:
text = "제1장적용기준31-1 일반사항····31-1-1 목적····131-2-6 공구 및 경장비····19제2장가설공사352-1 가설물의 한도 ····35"
assert const.toc_entries(text) == [
("1", "1", "적용기준"),
("1", "1-1", "일반사항"),
("1", "1-1-1", "목적"),
("1", "1-2-6", "공구 및 경장비"),
("2", "2", "가설공사"),
("2", "2-1", "가설물의 한도"),
]
assert const.toc_entries("제11장칠공사61111-1 공통사항····611")[1] == ("11", "11-1", "공통사항")
def test_인용은_제목이_아님() -> None:
names = {("공통부문", "3-2-4"): "터파기(기계)", ("공통부문", "3-2-5"): "되메우기"}
chapters = {"2", "3", "4"}
assert (
const.titles_in("’3-2-4 터파기(기계)’를 참고하여 적용한다.", "공통부문", chapters, names)
== []
)
assert const.titles_in("…경우3-2-4 터파기(기계)('25년 신설)", "공통부문", chapters, names) == [
"3-2-4"
]
assert const.titles_in("853-2-5 되메우기Q = n·q", "공통부문", chapters, names) == ["3-2-5"]
def test_목차_부문_다섯_장_마흔다섯(built: tuple) -> None:
items = built[0]["work_items"]
roots = [it for it in items if it["parent_code"] is None]
assert [r["name"] for r in roots] == list(const.DIVISIONS)
assert sum(1 for it in items if it["level"] == 2) == 45
codes = [it["work_item_code"] for it in items]
assert len(set(codes)) == len(codes)
def test_표는_다_붙고_못_붙인_표는_목록(built: tuple) -> None:
master = built[0]
stats = master["stats"]
assert stats["tables_total"] == 2192
assert stats["tables_attached"] + len(master["orphan_tables"]) == stats["tables_total"]
assert stats["tables_orphan"] == len(master["orphan_tables"]) == 0
def test_목차_오기_없음_연혁_붙은_본문_제목이_목차와_같음(built: tuple) -> None:
"""본문 제목 번호가 목차에 없거나 이름이 다르면 그 절 표가 **앞 절에 조용히 붙음** — 빨강."""
items = built[0]["work_items"]
names = {(it["division"], it["number"]): it["name"] for it in items if it["level"] > 1}
titled = re.compile(r"(\d+(?:-\d+)+)\s*([^’'\[\]()]{1,40}?)\s*\(\s*['‘’]\d\d")
data = json.loads(const.SOURCE.read_text(encoding="utf-8"))
files = sorted({t["source_file"] for t in data["variables"]["pum"]["tables"]})
seen, bad = 0, []
for source_file in files:
division, chapter = const.file_place(source_file)
chapters = {str(chapter - 1), str(chapter), str(chapter + 1)}
for line in (const.ROOT / source_file).read_text(encoding="utf-8").splitlines():
for found in titled.finditer(line):
raw, name = found.group(1), "".join(found.group(2).split())
number = next(
(
raw[cut:]
for cut in range(len(raw.split("-")[0]))
if raw[cut:].split("-")[0] in chapters
),
None,
)
if number is None:
continue
seen += 1
toc = "".join(names.get((division, number), "").split())
if not toc or not (name.startswith(toc) or toc.startswith(name)):
bad.append(
f"{division} {number} 「{found.group(2)}」 목차 「{names.get((division, number))}」"
)
assert seen > 600
assert not bad, bad
def test_열쇠_CW_안_겹침_다시_뽑아도_같음(built: tuple, master: dict) -> None:
keys = [it["work_item_key"] for it in master["work_items"]]
assert all(re.fullmatch(r"CW-\d{5}", k) for k in keys)
assert len(set(keys)) == len(keys)
assert built[3][1] == [] # 장부가 있으니 새로 안 냄
assert [it["work_item_key"] for it in built[0]["work_items"]] == keys
def test_산출_파일이_지금_코드로_뽑은_것(built: tuple, master: dict) -> None:
_same_except_time(built[0], master)
def test_산림_산출도_그대로_금액_불변() -> None:
"""표 읽기를 떼어내고(`_Table`) 건설과 나눠 써도 산림 산출은 한 글자도 안 바뀜."""
saved = json.loads((OUT / "work_item_master_2026-01-01.json").read_text(encoding="utf-8"))
master, undetermined, basis_missing, _ = forest.build()
_same_except_time(master, saved)
for name, fresh in (("form_undetermined", undetermined), ("basis_missing", basis_missing)):
saved_items = json.loads((OUT / f"{name}_2026-01-01.json").read_text(encoding="utf-8"))[
"items"
]
assert json.loads(json.dumps(fresh, ensure_ascii=False)) == saved_items
+20 -12
View File
@@ -1,7 +1,7 @@
"""Z01 공종 축 API 틀 — 산림·건설 두 kind 를 기초단가와 같은 길로(2026-09-17 브레인 · PLAN_공종축 8-4).
못박는 것: 평평한 잎 목록 · 전체 경로 이름(겹침 없음) · 계산 규칙(choose_one 91 · sum_steps 3) ·
불변 열쇠가 오면 `@id` 가 그것으로 · 건설은 자료가 있어야 상자가 섬 · 고치기는 막힘.
불변 열쇠가 `@id` · 건설도 같은 길(자료가 있어야 상자가 섬) · 고치기는 막힘.
"""
from __future__ import annotations
@@ -13,7 +13,6 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Router as router_module
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
@@ -46,7 +45,9 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
rows = table["rows"]
assert len({r["@id"] for r in rows}) == 360
assert len({r["name"] for r in rows}) == 360 # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1)
assert (
len({r["name"] for r in rows}) == 360
) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1)
by_code = {r["work_item_code"]: r for r in rows}
assert by_code["FP-09-03-02"]["name"] == "토공 › 토사깍기 › 기계"
@@ -63,12 +64,22 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non
assert found["total"] == 3
def test_불변_열쇠가_오면_id_로_씀_건설도_같은_길(
def test_건설_공종_축도_같은_길(client: TestClient) -> None:
"""건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one."""
table = _get(client, "work_item_const", size=500)
assert table["total"] == table["stats"]["leaves"] > 900
rows = table["rows"]
assert all(r["@id"].startswith("CW-") for r in rows)
assert all(r["name"].split(" › ")[0].endswith("부문") for r in rows)
assert {r["mode"] for r in table["rules"]} == {"choose_one"}
assert not any("불변 열쇠" in line for line in table["notice"])
found = _get(client, "work_item_const", q="굴착(인력/토사)")
assert [r["name"] for r in found["rows"]] == ["공통부문 › 토공사 › 굴착 › 굴착(인력/토사)"]
def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
if work_items.doc("work_item_construction") is None: # 빈 상자 안 세움
assert "work_item_construction" not in base_prices.KINDS
folder = tmp_path / "data_work_item_master"
folder.mkdir()
fake = {
@@ -87,20 +98,17 @@ def test_불변_열쇠가_오면_id_로_씀_건설도_같은_길(
json.dumps(fake, ensure_ascii=False), encoding="utf-8"
)
monkeypatch.setattr(work_items, "FOLDER", folder)
monkeypatch.setattr(work_items, "KINDS", tuple(work_items.SOURCE_OF))
monkeypatch.setattr(base_prices, "KINDS", (*base_prices.KINDS, "work_item_construction"))
table = _get(client, "work_item_construction")
table = _get(client, "work_item_const")
assert [r["@id"] for r in table["rows"]] == ["CW-00002"]
assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음
assert table["rows"][0]["name"] == "토공 › 인력"
assert table["stats"] == {"leaves": 1, "units": 2}
assert table["rules"][0]["steps"] == [{"@id": "CW-00002", "weight": "0.5"}]
assert not any("불변 열쇠" in line for line in table["notice"])
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
res = client.put(
"/api/master-data/base-prices/work_item_construction/CW-00002",
"/api/master-data/base-prices/work_item_const/CW-00002",
json={"values": {"name": "바꿈"}},
)
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]