fix(b08): 공종 축 목차 오기 둘을 본문 절 번호로 — 12-17-2 무근진동기 제외 → 12-17-3 · 12-24-1 지수판 설치 → 12-27-1(코덱스 원문 대조) · 혼성 줄 풀어 네 줄 제자리(F0363·F0364·F0371·F0375) · orphan 17 → 15 · 열쇠는 그대로(장부에 손으로 이음) · 바로잡은 기록 toc_corrections · 「표 절 제목이 다른 줄을 가리키면 빨강」 시험(옛 자료에서 빨강 확인) · 잎 358 → 360(브레인)

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:31:28 +09:00
co-authored by Claude Opus 5
parent 85c234871e
commit 1de5c8ab5b
8 changed files with 1868 additions and 1618 deletions
@@ -161,25 +161,66 @@ def parse_toc(rows: list[list[str]]) -> list[dict[str, Any]]:
else:
continue # 부록 등 — 공종 계층이 아니다.
order += 256 # STmate 의 SORTCODE 관례(256 간격) — 중간 삽입 여유.
parts = number.split("-")
nodes.append(
{
"work_item_code": f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts),
"number": number,
"name": name,
"level": len(parts),
"parent_code": (
f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts[:-1])
if len(parts) > 1
else None
),
"sort_order": order,
"tables": [],
}
)
nodes.append({"number": number, "name": name, "sort_order": order, "tables": []})
_set_number(nodes[-1], number)
return nodes
def _set_number(node: dict[str, Any], number: str) -> None:
"""번호에서 코드·단계·윗줄을 셈 — 목차를 읽을 때와 본문 번호로 바로잡을 때 한 벌."""
parts = number.split("-")
node.update(
{
"work_item_code": f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts),
"number": number,
"level": len(parts),
"parent_code": (
f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts[:-1])
if len(parts) > 1
else None
),
}
)
#: 본문 절 제목 — 「12-17-3. 무근진동기 제외」 꼴(번호 + 마침표 + 이름)만. 문장 조각은 안 봄.
_BODY_TITLE_RE = re.compile(r"^\s*(\d+(?:-\d+){0,3})\.\s+(.+)$")
def renumber_from_body(
nodes: list[dict[str, Any]], tables: list[dict[str, Any]]
) -> list[dict[str, str]]:
"""⚠ **목차 오기**를 본문 절 번호로 바로잡음 — 원문이 또렷하면 원문(2026-09-17 코덱스 원문 대조 · 브레인).
품셈 목차가 「12-17-2 무근진동기 제외」(본문 12-17-3) · 「12-24-1 지수판 설치」(본문 12-27-1) 로
**같은 번호를 두 번** 적었음. 목차만 믿으면 뒤 줄이 앞 표를 덮어써 이름과 표가 어긋난 줄이 서고
진짜 표(F0364·F0375)가 orphan 으로 사라짐 — 재료·노무·장비가 있는 원가 공종이라 금액이 빠짐.
⚠ 겹친 번호의 줄이고 · 본문에 같은 이름 제목이 있고 · 그 본문 번호가 목차에 없을 때만 고침.
셋 중 하나라도 아니면 그대로 둠(겹친 채 남아 `toc_duplicate_rows` 와 시험이 드러냄).
"""
body: dict[str, set[str]] = {}
for table in tables:
if found := _BODY_TITLE_RE.match(norm(table.get("section"))):
body.setdefault("".join(found.group(2).split()), set()).add(found.group(1))
count: dict[str, int] = {}
for node in nodes:
count[node["number"]] = count.get(node["number"], 0) + 1
fixed = []
for node in nodes:
numbers = body.get("".join(node["name"].split()), set())
if count[node["number"]] < 2 or node["number"] in numbers:
continue
candidates = sorted(numbers - set(count))
if len(candidates) != 1:
continue
printed = node["number"]
_set_number(node, candidates[0])
count[printed] -= 1
count[node["number"]] = 1
fixed.append({"name": node["name"], "toc_number": printed, "body_number": node["number"]})
return fixed
def _is_section_title(section: str, by_number: dict[str, dict[str, Any]]) -> bool:
"""진짜 절 제목인가 — 「번호. 이름」, 또는 마침표 없이 번호 뒤 글이 목차 절 이름과 같음."""
if re.match(r"^\s*\d+(?:-\d+){0,3}\.\s", section):
@@ -606,6 +647,7 @@ def build() -> tuple[
break
toc_table = next(t for t in tables if t["table_id"] == "F0001")
nodes = parse_toc(toc_table["rows"])
toc_corrections = renumber_from_body(nodes, tables)
by_number = {n["number"]: n for n in nodes}
attached = 0
@@ -786,7 +828,10 @@ def build() -> tuple[
"axis_roles": role_counts,
"keys_newly_issued": len(newly_issued),
"toc_duplicate_rows": len(toc_duplicates),
"toc_corrections": len(toc_corrections),
},
# 목차 오기를 본문 절 번호로 바로잡은 줄 — 목차에 적힌 번호(`toc_number`)도 남김.
"toc_corrections": toc_corrections,
# ⚠ 품셈 목차가 **같은 번호를 두 번 적은 자리**. 목차 번호를 열쇠로 쓰면 이 줄들이
# 서로 덮는다 — 열쇠를 따로 두는 까닭의 실물이다.
"toc_duplicate_rows": toc_duplicates,
@@ -1366,7 +1366,7 @@
"sources": [
"work_item_master::work_items"
],
"rows_note": "잎 358 줄 · 갈래 펴면 618",
"rows_note": "잎 360 줄 · 갈래 펴면 620",
"columns": [
{
"key": "work_item_code",
@@ -1763,7 +1763,7 @@
"key": [
"work_item_key"
],
"checked": "잎 358 줄 불변 열쇠(FW-) 겹침 0(전수 · `test_z01_work_items`). 목차 코드(FP-)는 칸으로만 — 개정 때 밀림."
"checked": "잎 360 줄 불변 열쇠(FW-) 겹침 0(전수 · `test_z01_work_items`). 목차 코드(FP-)는 칸으로만 — 개정 때 밀림."
}
],
"null_is_real": {
@@ -1861,11 +1861,11 @@
"merged_columns": 164,
"files": 34,
"tables": 92,
"columns": 472,
"value_groups": 176,
"columns": 471,
"value_groups": 177,
"unknown": 0,
"value_groups_by_kind": {
"doc": 110,
"doc": 111,
"value": 66
}
},
@@ -2066,7 +2066,7 @@
"name_ko": "공종에 못 붙인 표",
"summary": "목차 어디에도 못 붙인 품셈 표.",
"shape": "list",
"rows": 17,
"rows": 15,
"columns": [
{
"key": "pum_table_id",
@@ -2194,24 +2194,12 @@
]
},
{
"key": "toc_duplicate_rows",
"name_ko": "목차 번호가 겹친 줄",
"summary": "품셈 목차가 같은 번호를 두 번 적은 자리. 번호를 열쇠로 못 쓰는 실물이다.",
"key": "toc_corrections",
"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": "이름",
@@ -2219,8 +2207,14 @@
"visible": true
},
{
"key": "slot",
"name_ko": "장부 자리",
"key": "toc_number",
"name_ko": "목차에 적힌 번호",
"unit": "",
"visible": true
},
{
"key": "body_number",
"name_ko": "본문 절 번호",
"unit": "",
"visible": true
}
@@ -2245,6 +2239,12 @@
"name_ko": "목차 판(고시)",
"summary": "목차 번호가 묶인 고시 판. 값이 아니라 **판을 가리키는 표**다.",
"kind": "doc"
},
{
"key": "toc_duplicate_rows",
"name_ko": "목차 번호가 겹친 줄",
"summary": "목차가 같은 번호를 두 번 적었는데 본문 번호로도 못 가른 자리. 비어 있어야 정상 — 줄이 생기면 표가 딴 줄에 붙는다.",
"kind": "doc"
}
]
},
@@ -7059,6 +7059,11 @@
"unit": "",
"visible": true
},
"body_number": {
"name_ko": "본문 절 번호",
"unit": "",
"visible": true
},
"bond_codes": {
"name_ko": "쌓기 방식별 공종코드",
"unit": "",
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-17T10:24:52+09:00",
"generated_at": "2026-09-17T12:17:18+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": "6aad7aa1e526d306e7b7ab70229e364d24b0d236671e4e7b3ad1dac5a57e4b3b",
"size_bytes": 954644
"sha256": "e58445ea630862cc5391cd6c5d320c76065cdce92abd7e9f3c5dc55b71369273",
"size_bytes": 956887
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -399,7 +399,6 @@
"FP-12-17": "FW-00385",
"FP-12-17-01": "FW-00386",
"FP-12-17-02": "FW-00387",
"FP-12-17-02#2": "FW-00388",
"FP-12-18": "FW-00389",
"FP-12-19": "FW-00390",
"FP-12-20": "FW-00391",
@@ -412,7 +411,6 @@
"FP-12-25": "FW-00398",
"FP-12-26": "FW-00399",
"FP-12-27": "FW-00400",
"FP-12-24-01#2": "FW-00401",
"FP-12-27-02": "FW-00402",
"FP-12-27-03": "FW-00403",
"FP-12-28": "FW-00404",
@@ -488,7 +486,9 @@
"FP-13-16-02": "FW-00474",
"FP-14": "FW-00475",
"FP-14-01": "FW-00476",
"FP-14-02": "FW-00477"
"FP-14-02": "FW-00477",
"FP-12-17-03": "FW-00388",
"FP-12-27-01": "FW-00401"
}
},
"keys": {
@@ -3206,7 +3206,8 @@
"issued_on": "2026-09-17",
"first_toc_code": "FP-12-17-02",
"first_toc_number": "12-17-2",
"first_name": "무근진동기 제외"
"first_name": "무근진동기 제외",
"relinked": "목차 오기 바로잡음 — 목차 12-17-2(겹친 번호 · 자리 FP-12-17-02#2) → 본문 12-17-3 · 같은 공종이라 열쇠 그대로 (2026-09-17 코덱스 원문 대조 · 브레인 지시 · 손으로 이음)"
},
"FW-00389": {
"issued_edition": "산림청고시제2025-82호",
@@ -3297,7 +3298,8 @@
"issued_on": "2026-09-17",
"first_toc_code": "FP-12-24-01",
"first_toc_number": "12-24-1",
"first_name": "지수판 설치"
"first_name": "지수판 설치",
"relinked": "목차 오기 바로잡음 — 목차 12-24-1(겹친 번호 · 자리 FP-12-24-01#2) → 본문 12-27-1 · 같은 공종이라 열쇠 그대로 (2026-09-17 코덱스 원문 대조 · 브레인 지시 · 손으로 이음)"
},
"FW-00402": {
"issued_edition": "산림청고시제2025-82호",
File diff suppressed because it is too large Load Diff
+27 -11
View File
@@ -37,7 +37,10 @@ MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
#: 열쇠를 얹기 **전** 벌(`work_items`)의 지문. 2026-09-17 데스크탑_서브가 뜬 값.
#: ⚠ 이 값이 흔들리면 열쇠 작업이 값을 건드렸다는 뜻이다 — 지문을 고치지 말고 원인을 찾을 것.
BASELINE_WORK_ITEMS_SHA = "f8b8fba1ab5e6d1d5611b99148eecbed1eaf049c42d7154820f0d9a45e20ffea"
#: 2026-09-17 랩탑 메인 — 목차 오기 둘을 본문 번호로 바로잡아(브레인 지시) **네 줄만** 바뀌어 새로 뜸
#: (옛 f8b8fba1…). 네 줄 모습은 `test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로` 가 잼 ·
#: 미판정·밑수 목록 파일은 한 글자도 안 바뀜.
BASELINE_WORK_ITEMS_SHA = "be88f730b325c290012e5a2a79ad593ce5a74ffd82464226857191fefe6647e3"
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
ADDED_FIELDS = (
@@ -128,16 +131,29 @@ def test_열쇠_차례는_목차_차례() -> None:
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_목차_오기는_본문_번호로_바로잡고_열쇠는_그대(master: dict) -> None:
"""⚠ 품셈 목차가 12-17-2·12-24-1 을 두 번 적었음 — 본문은 12-17-3 무근진동기 제외 · 12-27-1 지수판 설치
(2026-09-17 코덱스 원문 대조). 목차를 믿으면 뒤 줄이 앞 표를 덮어써 혼성 줄이 서고 F0364·F0375 가 사라짐.
열쇠는 같은 공종이라 그대로(장부에 손으로 이음 · `relinked`)."""
assert master["toc_duplicate_rows"] == []
assert [(c["toc_number"], c["body_number"]) for c in master["toc_corrections"]] == [
("12-17-2", "12-17-3"),
("12-24-1", "12-27-1"),
]
by_key = {it["work_item_key"]: it for it in master["work_items"]}
for key, number, name, tables in (
("FW-00387", "12-17-2", "철근, 펌프카 0-15m", ["F0363"]),
("FW-00388", "12-17-3", "무근진동기 제외", ["F0364"]),
("FW-00396", "12-24-1", "뒷채움", ["F0371"]),
("FW-00401", "12-27-1", "지수판 설치", ["F0375"]),
):
it = by_key[key]
assert (it["number"], it["name"], [t["pum_table_id"] for t in it["tables"]]) == (
number,
name,
tables,
)
assert not {o["pum_table_id"] for o in master["orphan_tables"]} & {"F0364", "F0375"}
def test_슬롯_이름() -> None:
@@ -0,0 +1,64 @@
"""공종 축 — **이름과 붙은 표의 절이 어긋나면 빨강** (2026-09-17 브레인 · 코덱스 원문 대조).
이 병은 한 번 나면 아무도 못 알아챔: 품셈 목차가 같은 번호를 두 번 적자(12-17-2 · 12-24-1)
뒤 줄(「무근진동기 제외」)이 앞 표(F0363 「철근, 펌프카 0-15m」)를 물고, 진짜 표(F0364)는 orphan 으로 사라졌음.
표 번호로는 안 잡힘(번호는 같았음) — **표 절 제목의 이름이 다른 줄 이름과 똑같은데 그 줄이 아닌 데 붙음**이 그 꼴.
⚠ 이름 글자만 조금 다른 자리(목차 「깍기」 · 본문 「깎기」)는 판정하지 않음 — 번호·이름이 딴 줄을 가리킬 때만.
⚠ 부록 사례 표 7(F0455·F0456·F0472~F0476)은 번호가 본문 절과 겹쳐 그 절에 붙어 있으나 모두 참조형 —
일위대가로 안 읽혀 금액 무관(브레인 전수 판정). 자리 판정은 데스크탑 서브 몫.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MASTER = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
_TITLE = re.compile(r"^\s*(\d+(?:-\d+){0,3})\.\s+(.+)$")
def _squeeze(text: str) -> str:
return "".join(str(text).split())
@pytest.fixture(scope="module")
def master() -> dict:
return json.loads(MASTER.read_text(encoding="utf-8"))
def test_목차_번호가_안_겹침(master: dict) -> None:
numbers = [it["number"] for it in master["work_items"]]
assert len(set(numbers)) == len(numbers)
def test_표_절_제목이_다른_줄을_가리키지_않음(master: dict) -> None:
items = master["work_items"]
by_name: dict[str, set[str]] = {} # 이름 → 그 이름 줄의 열쇠(번호가 겹친 줄도 갈림)
for it in items:
by_name.setdefault(_squeeze(it["name"]), set()).add(it["work_item_key"])
bad = []
for it in items:
for table in it["tables"]:
title = _TITLE.match(table["section"])
if title is None:
continue
number, name = title.group(1), _squeeze(title.group(2))
others = by_name.get(name, set()) - {it["work_item_key"]}
if number != it["number"] or (others and name != _squeeze(it["name"])):
bad.append(
f"{table['pum_table_id']}{table['section']}」 → {it['number']} {it['name']}"
)
assert not bad, bad
def test_사라진_표는_목록으로_남음(master: dict) -> None:
"""orphan 을 조용히 버리지 않음 — 목록 수와 집계가 같아야 함."""
assert master["stats"]["tables_orphan"] == len(master["orphan_tables"])
assert (
master["stats"]["tables_attached"] + len(master["orphan_tables"])
== master["stats"]["tables_total"]
)
+4 -4
View File
@@ -39,14 +39,14 @@ def _get(client: TestClient, kind: str, **params) -> dict:
def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None:
listed = client.get("/api/master-data/base-prices").json()["kinds"]
forest = next(i for i in listed if i["kind"] == "work_item_forest")
assert forest["group"] == "work_item" and forest["rows"] == 358
assert forest["group"] == "work_item" and forest["rows"] == 360
table = _get(client, "work_item_forest", size=500)
assert table["total"] == 358 and table["stats"] == {"leaves": 358, "units": 618}
assert table["total"] == 360 and table["stats"] == {"leaves": 360, "units": 620}
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
rows = table["rows"]
assert len({r["@id"] for r in rows}) == 358
assert len({r["name"] for r in rows}) == 358 # 잎 이름 35줄 겹침 — 경로로 풀림
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)
by_code = {r["work_item_code"]: r for r in rows}
assert by_code["FP-09-03-02"]["name"] == "토공 토사깍기 기계"