Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1

랩탑 메인이 건설 공종 축·목차 오기 고침·파일 쪼개기를 냈음. 충돌 셋 해소:

- **열쇠 벌**(`_Keys.py`) — 랩탑의 갈래별 매개(`key_prefix`·`general_roots`)를 취하고,
  내 잣대 읽기(`axis_policy.json`)를 되살려 **기본값이 자료에서 옴**
  ⇒ 랩탑이 코드로 되돌려 둔 `GENERAL_PROVISION_ROOTS` 는 잣대를 못 읽는 자리용 기본값으로만 남김
- **산림 마스터·지문** — 랩탑 것(코덱스 원문 대조로 표 넷 제자리 · 잎 358 → 360)
- **이름표** — 랩탑이 건설 kind 를 정식으로 냈으므로 내 「기다리는 자리」(`pending_merged_tables`) 버림 ·
  공용 `columns` 여덟과 값 낱말 여섯은 살림 · `toc_duplicate_rows` 이름표는 뺌
  (오기 고침으로 빈 목록이 되어 「묵은 이름표」가 됨 — 다시 생기면 그때 붙일 것)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
This commit is contained in:
2026-09-17 13:32:47 +09:00
co-authored by Claude Opus 5
21 changed files with 206573 additions and 2329 deletions
+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 공종 축 건설
)
+51 -17
View File
@@ -39,7 +39,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 = (
@@ -130,16 +133,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:
@@ -205,18 +221,36 @@ def test_총칙_범위는_코드가_아니라_자료가_정함() -> None:
const = load_policy("const")
assert const["key_prefix"] == "CW"
assert const["settled"] is False
assert const["general_provision_roots"] == ["01_공통부문/제1장_적용기준.md"]
assert [p["chapter"] for p in const["general_provision_pending"]] == [
"01_공통부문/제8장_건설기계.md",
"05_유지관리부문/제1장_공통.md",
]
# ⚠ 건설 뿌리는 **부문 자리까지** — 부문마다 장 번호가 1부터 다시 시작한다.
assert const["general_provision_roots"] == ["CP-01-01"]
assert [p["root"] for p in const["general_provision_pending"]] == ["CP-01-08", "CP-05-01"]
# 뿌리를 바꿔 주면 구실도 따라 바뀐다 — 잣대가 자료에 있다는 증거
assert (
axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, roots=("FP-09",))
axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, general_roots=("FP-09",))
== "general_provision"
)
def test_건설_총칙_뿌리가_실제로_붙음() -> None:
"""잣대가 건설 산출에 닿았는가 — 뿌리 아래 37줄이 `general_provision` 으로 섰다."""
doc = json.loads(
(OUT_DIR / "const_work_item_master_2026-01-01.json").read_text(encoding="utf-8")
)
roles = doc["stats"]["axis_roles"]
assert roles["general_provision"] == 37
assert sum(roles.values()) == len(doc["work_items"]) == 1367
under = [
it
for it in doc["work_items"]
if it["work_item_code"] == "CP-01-01" or it["work_item_code"].startswith("CP-01-01-")
]
assert len(under) == 37
assert {it["axis_role"] for it in under} == {"general_provision"}
# ⬜ 미확정 둘은 아직 공종·묶음으로 서 있다 — 안티그래비티 답이 오면 뿌리에 더한다
기계 = next(it for it in doc["work_items"] if it["work_item_code"] == "CP-01-08")
assert 기계["axis_role"] != "general_provision"
def test_어느_구실도_지우지_않음() -> None:
"""브레인 승인(2026-09-17) — 「덜어내기」가 아니라 「가름만」."""
import json as _json
@@ -0,0 +1,151 @@
"""건설 공종 축(`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 / "const_work_item_master_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
def test_산림을_고르는_쪽이_건설_파일을_안_집음() -> None:
"""⚠ `work_item_master_*` 로 끝 파일을 고르는 자리가 있음(B08 밑수 대조 · B09 근거표) —
건설 산출을 `work_item_master_const_…` 로 두면 그쪽이 건설을 집어 밑수 대조가 통째로 빠졌음(2026-09-17)."""
from B08_Quantity.B08_Quantity_Engine_BasisUnit import load_master
assert load_master()["dataset_id"] == "work_item_master_forest"
assert not list(OUT.glob("work_item_master_*const*.json"))
@@ -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"]
)
+25 -17
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
@@ -39,14 +38,16 @@ 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"] == "토공 › 토사깍기 › 기계"
@@ -56,19 +57,29 @@ 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"
# 2026-09-17 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
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 = {
@@ -83,24 +94,21 @@ def test_불변_열쇠가_오면_id_로_씀_건설도_같은_길(
"variant_keys": ["갑", "을"]},
],
} # fmt: skip
(folder / "work_item_master_const_2026-01-01.json").write_text(
(folder / "const_work_item_master_2026-01-01.json").write_text(
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"]