Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 — 갈래 불변 열쇠(FW-00105#01 꼴 · 데스크탑 서브) 받음 · 자료 파일 충돌은 합친 코드로 다시 뽑아 풂 · 갈래 수 335 → 332(헛단위 13-3 갈래 뺀 것 반영)

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 18:23:43 +09:00
co-authored by Claude Opus 5
13 changed files with 4598 additions and 137 deletions
@@ -303,6 +303,7 @@ def finish(
# ⚠ 장부에 없는 목차 코드는 **새 열쇠**를 받는다. 짐작으로 옛 열쇠를 물려주지 않는다.
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import (
assign_keys,
assign_variant_keys,
decorate,
load_registry,
toc_edition,
@@ -311,6 +312,8 @@ def finish(
edition = toc_edition(data.get("sources"), data["effective_date"])
registry = load_registry(registry_path, key_prefix)
newly_issued, toc_duplicates = assign_keys(nodes, registry, edition=edition)
# 갈래도 불변 열쇠 — 갈래 글자를 열쇠에 담으면 원문이 다듬어질 때 열쇠가 갈린다(2026-09-17 브레인).
newly_variants = assign_variant_keys(nodes, registry)
decorate(nodes, edition=edition, roots=general_roots) # None = 산림 잣대(`axis_policy.json`)
role_counts: dict[str, int] = {}
for node in nodes:
@@ -357,6 +360,8 @@ def finish(
# 표도 아래도 없는 잎(`empty`). 화면·조합은 공종 축만 보면 된다.
"axis_roles": role_counts,
"keys_newly_issued": len(newly_issued),
"variant_keys_newly_issued": len(newly_variants),
"variant_keys": sum(len(n.get("variants") or []) for n in nodes),
"toc_duplicate_rows": len(toc_duplicates),
"toc_corrections": len(toc_corrections),
},
@@ -163,6 +163,82 @@ def assign_keys(
return newly, duplicates
#: 갈래 열쇠 자릿수 — `FW-00105#01` 의 뒤 두 자리.
VARIANT_DIGITS = 2
#: 원문이 섞어 쓰는 물결표 — 맞댈 때 하나로 모은다.
_TILDES = {"": "", "": "", "~": ""}
def variant_match(text: str) -> str:
"""갈래를 맞대는 꼴 — **공백을 다 떼고** 물결표를 하나로.
⚠ 왜 이렇게까지 지우나 — 원문이 「간 단」·「간 단」처럼 자간을 들쭉날쭉 쓰고, 이름 다듬기가
앞으로 더 들어올 자리다. 맞대는 꼴이 공백에 걸리면 **다듬는 날 열쇠가 갈린다.**
(산림 335 갈래로 재 보니 한 공종 안에서 이 꼴이 겹치는 자리는 없다 — 2026-09-17.)
"""
squeezed = "".join(str(text or "").split())
return "".join(_TILDES.get(ch, ch) for ch in squeezed)
def variant_display(text: str) -> str:
"""다듬은 갈래 이름 — 자간 공백을 한 칸으로, 물결표를 하나로. 원문은 따로 남긴다."""
spaced = " ".join(str(text or "").split())
return "".join(_TILDES.get(ch, ch) for ch in spaced)
def format_variant(serial: int) -> str:
return f"{serial:0{VARIANT_DIGITS}d}"
def assign_variant_keys(
nodes: list[dict[str, Any]], registry: dict[str, Any], *, issued_on: str | None = None
) -> list[dict[str, str]]:
"""갈래마다 **불변 열쇠**(`FW-00105#01`)를 주고 `variants` 칸을 얹는다.
공종 열쇠와 같은 병을 갈래가 그대로 앓고 있었다 — 갈래 글자가 열쇠에 들어가 있어
**원문 자간이나 물결표가 손질되면 열쇠가 갈렸다.** 그래서 장부에 올린다(2026-09-17 브레인).
장부 자리는 **공종 열쇠 아래**다(목차 코드 아래가 아니다) — 목차 번호가 밀려도 안 흔들린다.
⚠ 장부에 없는 갈래는 **새 번호**를 받는다. 비슷하다고 옛 번호를 물려주지 않는다.
사라진 갈래의 번호는 **비워 둔 채 다시 쓰지 않는다** — 옛 일위대가가 가리키던 자리다.
"""
issued_on = issued_on or date.today().isoformat()
newly: list[dict[str, str]] = []
for node in sorted(nodes, key=lambda n: n["sort_order"]):
raw = node.get("variant_keys") or []
if not raw:
node["variants"] = []
continue
# ⚠ 갈래가 하나도 없으면 장부에 빈 칸을 만들지 않는다 — 건설처럼 아직 갈래가 없는 벌에
# 빈 `variants` 가 생기면 이름표가 「있는데 이름이 없는 표」로 잡는다.
book = registry.setdefault("variants", {})
entry = book.setdefault(node["work_item_key"], {"next_serial": 1, "slots": {}})
variants = []
for text in raw:
slot = variant_match(text)
serial = entry["slots"].get(slot)
if serial is None:
serial = format_variant(entry["next_serial"])
entry["next_serial"] += 1
entry["slots"][slot] = serial
newly.append(
{
"variant_key": f"{node['work_item_key']}#{serial}",
"name": text,
"issued_on": issued_on,
}
)
variants.append(
{
"variant_key": serial,
"name": text, # 원문 그대로 — 손대지 않는다
"name_clean": variant_display(text), # 다듬은 것
}
)
node["variants"] = variants
return newly
def path_names(nodes: list[dict[str, Any]]) -> list[str]:
"""`["토공 토사깍기 인력", …]` — `nodes` 차례에 맞춘 전체 경로 이름.
+6 -4
View File
@@ -91,17 +91,19 @@ def base_rows(kind: str) -> list[dict[str, Any]]:
)
key = _key(item)
# 갈래마다 한 줄 — 일위대가가 갈래마다 달라 집는 단위가 갈래임(교목굴취 나무높이 9 · 2026-09-17 브레인 ③).
# 열쇠 = 공종 열쇠#갈래 키(B08·B09 `코드#갈래` 꼴) · 갈래 키 글자는 마스터 것 그대로(조립 두 벌 금지).
for variant in item.get("variant_keys") or [None]:
# 열쇠 = `공종 열쇠#갈래 번호`(`FW-00105#01`) — **갈래 글자를 열쇠에 담지 않는다.**
# 담으면 원문 자간·물결표가 다듬어지는 날 열쇠가 갈린다(2026-09-17 브레인 · 장부가 번호를 준다).
for variant in item.get("variants") or [None]:
rows.append(
{
"@id": f"{key}#{variant}" if variant else key,
"@id": f"{key}#{variant['variant_key']}" if variant else key,
"@key": key, # 공종 열쇠 — 거르기·줄 수는 공종 단위로
"@source": name,
"work_item_code": item["work_item_code"], # 목차 코드 — 열쇠 아님, 칸으로만
"number": item["number"],
"name": _path(item, by),
"variant": variant,
"variant": variant["name"] if variant else None, # 원문 그대로
"variant_clean": variant["name_clean"] if variant else None, # 다듬은 것
"pum_tables": [t["pum_table_id"] for t in item["tables"]],
"rule": (named.get(f"work_item_master::{parent.get('parent_mode')}") or {}).get(
"name_ko"
@@ -1395,6 +1395,13 @@
"visible": true,
"note": "갈래마다 한 줄(일위대가가 갈래마다 다름) — 줄 열쇠는 공종 열쇠#갈래."
},
{
"key": "variant_clean",
"name_ko": "갈래(다듬은 것)",
"unit": "",
"visible": false,
"note": "자간 공백을 한 칸으로 · 물결표를 하나로. 열쇠는 글자가 아니라 번호(`#01`)다."
},
{
"key": "pum_tables",
"name_ko": "딸린 품셈 표",
@@ -1473,6 +1480,13 @@
"visible": true,
"note": "갈래마다 한 줄(일위대가가 갈래마다 다름) — 줄 열쇠는 공종 열쇠#갈래."
},
{
"key": "variant_clean",
"name_ko": "갈래(다듬은 것)",
"unit": "",
"visible": false,
"note": "자간 공백을 한 칸으로 · 물결표를 하나로. 열쇠는 글자가 아니라 번호(`#01`)다."
},
{
"key": "pum_tables",
"name_ko": "딸린 품셈 표",
@@ -1960,10 +1974,10 @@
},
"counts": {
"merged_tables": 17,
"merged_columns": 176,
"merged_columns": 178,
"files": 41,
"tables": 100,
"columns": 534,
"tables": 101,
"columns": 540,
"value_groups": 203,
"unknown": 0,
"value_groups_by_kind": {
@@ -2245,6 +2259,12 @@
"unit": "",
"visible": true
},
{
"key": "variants",
"name_ko": "갈래(열쇠·이름)",
"unit": "",
"visible": true
},
{
"key": "steps",
"name_ko": "단계 합산 구성",
@@ -7143,6 +7163,12 @@
"unit": "",
"visible": true
},
{
"key": "variants",
"name_ko": "갈래(열쇠·이름)",
"unit": "",
"visible": true
},
{
"key": "work_item_key",
"name_ko": "공종 열쇠",
@@ -7385,6 +7411,34 @@
"visible": true
}
]
},
{
"key": "variants",
"name_ko": "갈래 열쇠 장부",
"summary": "공종 열쇠 아래 갈래마다 번호를 매긴 것. 사라진 갈래의 번호는 다시 쓰지 않는다.",
"shape": "map",
"rows": 66,
"columns": [
{
"key": "@key",
"name_ko": "갈래를 지닌 산림 공종",
"unit": "",
"visible": true,
"is_row_key": true
},
{
"key": "next_serial",
"name_ko": "다음 갈래 번호",
"unit": "",
"visible": true
},
{
"key": "slots",
"name_ko": "갈래 자리→번호",
"unit": "",
"visible": true
}
]
}
],
"value_groups": [
@@ -7675,6 +7729,12 @@
"name_ko": "줄 구실 셈",
"unit": "",
"visible": true
},
{
"key": "settled_note",
"name_ko": "확정 덧말",
"unit": "",
"visible": true
}
]
}
@@ -8368,11 +8428,21 @@
"unit": "",
"visible": true
},
"name_clean": {
"name_ko": "다듬은 이름",
"unit": "",
"visible": true
},
"needs": {
"name_ko": "있어야 열리는 것",
"unit": "",
"visible": true
},
"next_serial": {
"name_ko": "다음 일련번호",
"unit": "",
"visible": true
},
"note": {
"name_ko": "비고",
"unit": "",
@@ -8698,6 +8768,11 @@
"unit": "byte",
"visible": false
},
"slots": {
"name_ko": "갈래 자리→번호",
"unit": "",
"visible": true
},
"soil_type": {
"name_ko": "토질",
"unit": "",
@@ -8863,11 +8938,21 @@
"unit": "",
"visible": true
},
"variant_clean": {
"name_ko": "갈래(다듬은 것)",
"unit": "",
"visible": true
},
"variant_from": {
"name_ko": "갈래 원본 칸",
"unit": "",
"visible": true
},
"variant_key": {
"name_ko": "갈래 번호",
"unit": "",
"visible": true
},
"variant_keys": {
"name_ko": "갈래 키",
"unit": "",
@@ -8893,6 +8978,11 @@
"unit": "",
"visible": true
},
"variants": {
"name_ko": "갈래",
"unit": "",
"visible": true
},
"variation_coefficient": {
"name_ko": "변동계수",
"unit": "%",
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-17T17:19:56+09:00",
"generated_at": "2026-09-17T18:21:40+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": "aa065ec629bde8af403cf9246e0ef65d1870da80c6eaea14f26c06a5cd5563d5",
"size_bytes": 870603
"sha256": "1cae4b3cdba4b7c13b1d0e7854a2754da3bda9402754263e801287fe20d019c7",
"size_bytes": 925084
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-17T17:19:56+09:00",
"generated_at": "2026-09-17T18:21:40+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_const",
@@ -12,8 +12,8 @@
"files": [
{
"file": "const_work_item_master_2026-01-01.json",
"sha256": "4f7d61f283d07626501ff0ca0a0446aa99a118980360e4fdcac802899e174086",
"size_bytes": 3752575
"sha256": "52ac9f352106e61cbb0d548763808f967afb152e17eb6dce4a1cd1dd51f59e7d",
"size_bytes": 3780649
},
{
"file": "const_form_undetermined_2026-01-01.json",
@@ -74,7 +74,7 @@
},
"const": {
"name_ko": "건설 공종",
"settled": false,
"settled": true,
"source": "resources/data_cost_input_value/pum_const_2026.json",
"toc_source": "합본 원문 머리 「목 차」 글줄 — 목차표가 없어 랩탑 메인이 글에서 읽음(부문 5 · 장 45 · 1,367줄)",
"code_prefix": "CP",
@@ -82,26 +82,11 @@
"code_shape": "CP-<부문2>-<장2>-<절…>",
"code_shape_why": "⚠ **부문마다 장 번호가 1부터 다시 시작한다** — 공통 1장은 적용기준, 토목 1장은 도로포장공사다. 부문 자리가 없으면 부문이 다른 공종끼리 덮는다(산림에서 목차 번호 겹침 둘이 실제로 덮고 있었다).",
"general_provision_roots": [
"01_공통부문/제1장_적용기준.md"
],
"general_provision_basis": "공통부문 제1장 적용기준(`CP-01-01`) — 줄 37 · 표 46. 산림 FP-01 적용기준과 짝. 단위표준·토질·할증 같은 기준표다.",
"general_provision_pending": [
{
"chapter": "01_공통부문/제8장_건설기계.md",
"code": "CP-01-08",
"rows": 75,
"tables": 351,
"why": "산림 FP-02 「소요재료 및 기계손료」와 짝으로 보이나, 기계 시공능력·손료 파라미터라 공종으로 볼 여지도 있음. ⬜ 안티그래비티 조사 대기.",
"already_extracted": "C0426·C0427 은 `resources/data_machine_productivity/` 로 이미 꺼냈음"
},
{
"chapter": "05_유지관리부문/제1장_공통.md",
"code": "CP-05-01",
"rows": 37,
"tables": 40,
"why": "이름이 「공 통」 — 기준표 장인지 공종 장인지 원문 확인 필요. ⬜ 안티그래비티 조사 대기."
}
"01_공통부문/제1장_적용기준.md",
"01_공통부문/제8장_건설기계.md"
],
"general_provision_basis": "공통부문 제1장 적용기준(`CP-01-01`) 줄 37 · 표 46 — 산림 FP-01 과 짝. 공통부문 제8장 건설기계(`CP-01-08`) 줄 75 · 표 351 — 산림 FP-02(소요재료 및 기계손료)와 짝. 시공능력·손료 파라미터라 값을 정하는 자리다(C0426·C0427 은 이미 기초데이터로 꺼냈다). 2026-09-17 안티그래비티 조사 · 브레인 확정.",
"general_provision_pending": [],
"known_defects": [
"형태 미판정 881 / 2,192 — B09 표 읽기가 산림 표로만 검증됨",
"밑수 미확보 447 — 곱하면 안 되는 줄이라 받는 쪽이 가려야 함",
@@ -115,7 +100,8 @@
"general_provision": 0,
"empty": 100,
"note": "총칙 표시 전 셈(랩탑 메인 첫 산출). 뿌리를 붙이면 `general_provision` 으로 옮겨 간다 — 줄이 줄지는 않는다."
}
},
"settled_note": "유지관리부문 제1장 「공 통」(`CP-05-01`) 표 40 은 **공종**으로 둔다 — 총칙이 아니다(2026-09-17 안티그래비티 조사 · 브레인 확정)."
}
},
"list_default": {
File diff suppressed because it is too large Load Diff
@@ -3833,5 +3833,672 @@
"first_toc_number": "14-2",
"first_name": "근주이식"
}
},
"variants": {
"FW-00097": {
"next_serial": 4,
"slots": {
"5m미만": "01",
"5m이상∼8m미만": "02",
"8m이상": "03"
}
},
"FW-00104": {
"next_serial": 5,
"slots": {
"0.3m미만": "01",
"0.30.7m이하": "02",
"0.81.1m이하": "03",
"1.21.5m이하": "04"
}
},
"FW-00105": {
"next_serial": 10,
"slots": {
"1.0이하": "01",
"1.11.5": "02",
"1.62.0": "03",
"2.12.5": "04",
"2.63.0": "05",
"3.13.5": "06",
"3.64.0": "07",
"4.14.5": "08",
"4.65.0": "09"
}
},
"FW-00107": {
"next_serial": 3,
"slots": {
"줄떼": "01",
"평떼": "02"
}
},
"FW-00112": {
"next_serial": 5,
"slots": {
"0.3m미만": "01",
"0.30.7m이하": "02",
"0.81.1m이하": "03",
"1.21.5m이하": "04"
}
},
"FW-00113": {
"next_serial": 5,
"slots": {
"0.3m미만": "01",
"0.30.7m이하": "02",
"0.81.1m이하": "03",
"1.21.5m이하": "04"
}
},
"FW-00125": {
"next_serial": 3,
"slots": {
"줄떼심기": "01",
"띠떼심기": "02"
}
},
"FW-00129": {
"next_serial": 4,
"slots": {
"보통토사": "01",
"경질ㆍ고사점토및자갈섞인점토": "02",
"호박돌섞인토사": "03"
}
},
"FW-00139": {
"next_serial": 4,
"slots": {
"채취": "01",
"운반": "02",
"붙이기": "03"
}
},
"FW-00160": {
"next_serial": 3,
"slots": {
"폭1.5m이하": "01",
"폭2.0m이하": "02"
}
},
"FW-00171": {
"next_serial": 4,
"slots": {
"1,500본미만": "01",
"1,500본이상3,000본미만": "02",
"3,000본이상": "03"
}
},
"FW-00172": {
"next_serial": 4,
"slots": {
"조림당해연도(전년도추기조림포함)": "01",
"조림2년차": "02",
"조림3년차이상": "03"
}
},
"FW-00234": {
"next_serial": 3,
"slots": {
"소형헬기(160ha당)": "01",
"대형헬기(400ha당)": "02"
}
},
"FW-00236": {
"next_serial": 2,
"slots": {
"차량살포(동력분무기)": "01"
}
},
"FW-00242": {
"next_serial": 3,
"slots": {
"1㎥": "01",
"2㎥": "02"
}
},
"FW-00250": {
"next_serial": 5,
"slots": {
"연암": "01",
"보통암": "02",
"경암": "03",
"평균": "04"
}
},
"FW-00251": {
"next_serial": 5,
"slots": {
"연암": "01",
"보통암": "02",
"경암": "03",
"평균": "04"
}
},
"FW-00253": {
"next_serial": 4,
"slots": {
"연암": "01",
"보통암": "02",
"경암": "03"
}
},
"FW-00255": {
"next_serial": 4,
"slots": {
"연암": "01",
"보통암": "02",
"경암": "03"
}
},
"FW-00309": {
"next_serial": 11,
"slots": {
"절토면·모래ㆍ사질토ㆍ점토ㆍ점질토": "01",
"절토면·연질토ㆍ불순자갈": "02",
"절토면·호박돌섞인고결토ㆍ경질토": "03",
"절토면·풍화암": "04",
"절토면·연암": "05",
"절토면·보통암ㆍ경암": "06",
"성토면·인력·점토또는점질토": "07",
"성토면·인력·모래또는사질토": "08",
"성토면·기계·무한궤도": "09",
"성토면·기계·타이어": "10"
}
},
"FW-00315": {
"next_serial": 7,
"slots": {
"굴착기(무한궤도)0.2·소": "01",
"굴착기(무한궤도)0.2·중": "02",
"굴착기(무한궤도)0.2·밀": "03",
"굴착기(무한궤도)0.7·소": "04",
"굴착기(무한궤도)0.7·중": "05",
"굴착기(무한궤도)0.7·밀": "06"
}
},
"FW-00344": {
"next_serial": 4,
"slots": {
"토사": "01",
"파쇄암": "02",
"발파암": "03"
}
},
"FW-00353": {
"next_serial": 21,
"slots": {
"길이3M·폭2.4M": "01",
"길이3M·폭3.0M": "02",
"길이3M·폭3.5M": "03",
"길이3M·폭4.8M": "04",
"길이3M·폭6.0M": "05",
"길이6M·폭2.4M": "06",
"길이6M·폭3.0M": "07",
"길이6M·폭3.5M": "08",
"길이6M·폭4.8M": "09",
"길이6M·폭6.0M": "10",
"길이9M·폭2.4M": "11",
"길이9M·폭3.0M": "12",
"길이9M·폭3.5M": "13",
"길이9M·폭4.8M": "14",
"길이9M·폭6.0M": "15",
"길이12M·폭2.4M": "16",
"길이12M·폭3.0M": "17",
"길이12M·폭3.5M": "18",
"길이12M·폭4.8M": "19",
"길이12M·폭6.0M": "20"
}
},
"FW-00359": {
"next_serial": 4,
"slots": {
"무근구조물": "01",
"철근구조물": "02",
"소형구조물": "03"
}
},
"FW-00360": {
"next_serial": 4,
"slots": {
"무근구조물": "01",
"철근구조물": "02",
"소형구조물": "03"
}
},
"FW-00361": {
"next_serial": 4,
"slots": {
"무근구조물": "01",
"철근구조물": "02",
"소형구조물": "03"
}
},
"FW-00363": {
"next_serial": 5,
"slots": {
"간단": "01",
"보통": "02",
"복잡": "03",
"매우복잡": "04"
}
},
"FW-00364": {
"next_serial": 7,
"slots": {
"1회": "01",
"2회": "02",
"3회": "03",
"4회": "04",
"5회": "05",
"6회": "06"
}
},
"FW-00366": {
"next_serial": 4,
"slots": {
"20㎝": "01",
"30㎝": "02",
"40㎝": "03"
}
},
"FW-00377": {
"next_serial": 4,
"slots": {
"∅800mm": "01",
"∅1000mm": "02",
"∅1200mm": "03"
}
},
"FW-00378": {
"next_serial": 4,
"slots": {
"∅800mm": "01",
"∅1000mm": "02",
"∅1200mm": "03"
}
},
"FW-00379": {
"next_serial": 4,
"slots": {
"∅800mm": "01",
"∅1000mm": "02",
"∅1200mm": "03"
}
},
"FW-00380": {
"next_serial": 2,
"slots": {
"콘크리트": "01"
}
},
"FW-00381": {
"next_serial": 2,
"slots": {
"콘크리트": "01"
}
},
"FW-00383": {
"next_serial": 3,
"slots": {
"구체콘크리트": "01",
"버림콘크리트": "02"
}
},
"FW-00384": {
"next_serial": 3,
"slots": {
"구체콘크리트": "01",
"버림콘크리트": "02"
}
},
"FW-00386": {
"next_serial": 5,
"slots": {
"무근콘크리트": "01",
"철근콘크리트": "02",
"설치": "03",
"철거": "04"
}
},
"FW-00396": {
"next_serial": 2,
"slots": {
"뒷채움자재비및운반비별산": "01"
}
},
"FW-00397": {
"next_serial": 2,
"slots": {
"뒷채움자재비및운반비별산": "01"
}
},
"FW-00420": {
"next_serial": 4,
"slots": {
"간단": "01",
"보통": "02",
"복잡": "03"
}
},
"FW-00422": {
"next_serial": 4,
"slots": {
"간단": "01",
"보통": "02",
"복잡": "03"
}
},
"FW-00423": {
"next_serial": 4,
"slots": {
"복잡": "01",
"보통": "02",
"간단": "03"
}
},
"FW-00428": {
"next_serial": 3,
"slots": {
"㎡당": "01",
"㎥당": "02"
}
},
"FW-00430": {
"next_serial": 11,
"slots": {
"25㎝·㎡당": "01",
"35㎝·㎡당": "02",
"45㎝·㎡당": "03",
"55㎝·㎡당": "04",
"60㎝·㎡당": "05",
"25㎝·㎥당": "06",
"35㎝·㎥당": "07",
"45㎝·㎥당": "08",
"55㎝·㎥당": "09",
"60㎝·㎥당": "10"
}
},
"FW-00431": {
"next_serial": 4,
"slots": {
"35cm이하": "01",
"55cm이하": "02",
"75cm이하": "03"
}
},
"FW-00432": {
"next_serial": 4,
"slots": {
"두께3㎝": "01",
"두께6㎝": "02",
"자갈기초다짐지름13㎝": "03"
}
},
"FW-00435": {
"next_serial": 41,
"slots": {
"깬잡석·골쌓기·뒷길이25㎝": "01",
"깬잡석·켜쌓기·뒷길이25㎝": "02",
"호박돌및야면석·뒷길이25㎝": "03",
"깬돌·골쌓기·뒷길이30㎝": "04",
"깬돌·켜쌓기·뒷길이30㎝": "05",
"깬잡석·골쌓기·뒷길이30㎝": "06",
"깬잡석·켜쌓기·뒷길이30㎝": "07",
"호박돌및야면석·뒷길이30㎝": "08",
"견치돌·골쌓기·뒷길이35㎝": "09",
"견치돌·켜쌓기·뒷길이35㎝": "10",
"깬돌·골쌓기·뒷길이35㎝": "11",
"깬돌·켜쌓기·뒷길이35㎝": "12",
"깬잡석·골쌓기·뒷길이35㎝": "13",
"깬잡석·켜쌓기·뒷길이35㎝": "14",
"호박돌및야면석·뒷길이35㎝": "15",
"견치돌·골쌓기·뒷길이45㎝": "16",
"견치돌·켜쌓기·뒷길이45㎝": "17",
"깬돌·골쌓기·뒷길이45㎝": "18",
"깬돌·켜쌓기·뒷길이45㎝": "19",
"깬잡석·골쌓기·뒷길이45㎝": "20",
"깬잡석·켜쌓기·뒷길이45㎝": "21",
"호박돌및야면석·뒷길이45㎝": "22",
"견치돌·골쌓기·뒷길이55㎝": "23",
"견치돌·켜쌓기·뒷길이55㎝": "24",
"깬돌·골쌓기·뒷길이55㎝": "25",
"깬돌·켜쌓기·뒷길이55㎝": "26",
"깬잡석·골쌓기·뒷길이55㎝": "27",
"깬잡석·켜쌓기·뒷길이55㎝": "28",
"호박돌및야면석·뒷길이55㎝": "29",
"견치돌·골쌓기·뒷길이60㎝": "30",
"견치돌·켜쌓기·뒷길이60㎝": "31",
"깬돌·골쌓기·뒷길이60㎝": "32",
"깬돌·켜쌓기·뒷길이60㎝": "33",
"깬잡석·골쌓기·뒷길이60㎝": "34",
"깬잡석·켜쌓기·뒷길이60㎝": "35",
"호박돌및야면석·뒷길이60㎝": "36",
"깬돌·골쌓기·뒷길이75㎝": "37",
"깬돌·켜쌓기·뒷길이75㎝": "38",
"깬잡석·골쌓기·뒷길이75㎝": "39",
"깬잡석·켜쌓기·뒷길이75㎝": "40"
}
},
"FW-00436": {
"next_serial": 4,
"slots": {
"35cm이하": "01",
"55cm이하": "02",
"75cm이하": "03"
}
},
"FW-00438": {
"next_serial": 41,
"slots": {
"깬잡석·골쌓기·뒷길이25㎝": "01",
"깬잡석·켜쌓기·뒷길이25㎝": "02",
"호박돌및야면석·뒷길이25㎝": "03",
"깬돌·골쌓기·뒷길이30㎝": "04",
"깬돌·켜쌓기·뒷길이30㎝": "05",
"깬잡석·골쌓기·뒷길이30㎝": "06",
"깬잡석·켜쌓기·뒷길이30㎝": "07",
"호박돌및야면석·뒷길이30㎝": "08",
"견치돌·골쌓기·뒷길이35㎝": "09",
"견치돌·켜쌓기·뒷길이35㎝": "10",
"깬돌·골쌓기·뒷길이35㎝": "11",
"깬돌·켜쌓기·뒷길이35㎝": "12",
"깬잡석·골쌓기·뒷길이35㎝": "13",
"깬잡석·켜쌓기·뒷길이35㎝": "14",
"호박돌및야면석·뒷길이35㎝": "15",
"견치돌·골쌓기·뒷길이45㎝": "16",
"견치돌·켜쌓기·뒷길이45㎝": "17",
"깬돌·골쌓기·뒷길이45㎝": "18",
"깬돌·켜쌓기·뒷길이45㎝": "19",
"깬잡석·골쌓기·뒷길이45㎝": "20",
"깬잡석·켜쌓기·뒷길이45㎝": "21",
"호박돌및야면석·뒷길이45㎝": "22",
"견치돌·골쌓기·뒷길이55㎝": "23",
"견치돌·켜쌓기·뒷길이55㎝": "24",
"깬돌·골쌓기·뒷길이55㎝": "25",
"깬돌·켜쌓기·뒷길이55㎝": "26",
"깬잡석·골쌓기·뒷길이55㎝": "27",
"깬잡석·켜쌓기·뒷길이55㎝": "28",
"호박돌및야면석·뒷길이55㎝": "29",
"견치돌·골쌓기·뒷길이60㎝": "30",
"견치돌·켜쌓기·뒷길이60㎝": "31",
"깬돌·골쌓기·뒷길이60㎝": "32",
"깬돌·켜쌓기·뒷길이60㎝": "33",
"깬잡석·골쌓기·뒷길이60㎝": "34",
"깬잡석·켜쌓기·뒷길이60㎝": "35",
"호박돌및야면석·뒷길이60㎝": "36",
"깬돌·골쌓기·뒷길이75㎝": "37",
"깬돌·켜쌓기·뒷길이75㎝": "38",
"깬잡석·골쌓기·뒷길이75㎝": "39",
"깬잡석·켜쌓기·뒷길이75㎝": "40"
}
},
"FW-00439": {
"next_serial": 4,
"slots": {
"35cm이하": "01",
"55cm이하": "02",
"75cm이하": "03"
}
},
"FW-00440": {
"next_serial": 4,
"slots": {
"25㎝": "01",
"30㎝": "02",
"35㎝": "03"
}
},
"FW-00442": {
"next_serial": 41,
"slots": {
"메붙임·깬돌·뒷길이25㎝": "01",
"메붙임·깬돌·뒷길이30㎝": "02",
"메붙임·깬돌·뒷길이35㎝": "03",
"메붙임·깬돌·뒷길이45㎝": "04",
"메붙임·깬돌·뒷길이55㎝": "05",
"메붙임·깬돌·뒷길이60㎝": "06",
"메붙임·깬돌·뒷길이70㎝": "07",
"메붙임·깬잡석·뒷길이25㎝": "08",
"메붙임·깬잡석·뒷길이30㎝": "09",
"메붙임·깬잡석·뒷길이35㎝": "10",
"메붙임·깬잡석·뒷길이45㎝": "11",
"메붙임·깬잡석·뒷길이55㎝": "12",
"메붙임·깬잡석·뒷길이60㎝": "13",
"메붙임·깬잡석·뒷길이70㎝": "14",
"메붙임·조약돌및야면석·뒷길이25㎝": "15",
"메붙임·조약돌및야면석·뒷길이30㎝": "16",
"메붙임·조약돌및야면석·뒷길이35㎝": "17",
"메붙임·조약돌및야면석·뒷길이45㎝": "18",
"메붙임·조약돌및야면석·뒷길이55㎝": "19",
"메붙임·조약돌및야면석·뒷길이60㎝": "20",
"찰붙임·깬돌·뒷길이25㎝": "21",
"찰붙임·깬돌·뒷길이30㎝": "22",
"찰붙임·깬돌·뒷길이35㎝": "23",
"찰붙임·깬돌·뒷길이45㎝": "24",
"찰붙임·깬돌·뒷길이55㎝": "25",
"찰붙임·깬돌·뒷길이60㎝": "26",
"찰붙임·깬돌·뒷길이70㎝": "27",
"찰붙임·깬잡석·뒷길이25㎝": "28",
"찰붙임·깬잡석·뒷길이30㎝": "29",
"찰붙임·깬잡석·뒷길이35㎝": "30",
"찰붙임·깬잡석·뒷길이45㎝": "31",
"찰붙임·깬잡석·뒷길이55㎝": "32",
"찰붙임·깬잡석·뒷길이60㎝": "33",
"찰붙임·깬잡석·뒷길이70㎝": "34",
"찰붙임·호박돌및야면석·뒷길이25㎝": "35",
"찰붙임·호박돌및야면석·뒷길이30㎝": "36",
"찰붙임·호박돌및야면석·뒷길이35㎝": "37",
"찰붙임·호박돌및야면석·뒷길이45㎝": "38",
"찰붙임·호박돌및야면석·뒷길이55㎝": "39",
"찰붙임·호박돌및야면석·뒷길이60㎝": "40"
}
},
"FW-00443": {
"next_serial": 4,
"slots": {
"35cm이하": "01",
"55cm이하": "02",
"75cm이하": "03"
}
},
"FW-00445": {
"next_serial": 4,
"slots": {
"직경40㎝이상∼60㎝미만": "01",
"직경60㎝이상∼80㎝미만": "02",
"직경80㎝이상∼100㎝이하": "03"
}
},
"FW-00446": {
"next_serial": 4,
"slots": {
"직경40㎝이상∼60㎝미만": "01",
"직경60㎝이상∼80㎝미만": "02",
"직경80㎝이상∼100㎝이하": "03"
}
},
"FW-00447": {
"next_serial": 4,
"slots": {
"직경40㎝이상∼60㎝미만": "01",
"직경60㎝이상∼80㎝미만": "02",
"직경80㎝이상∼100㎝이하": "03"
}
},
"FW-00449": {
"next_serial": 4,
"slots": {
"직경40㎝이상∼60㎝미만": "01",
"직경60㎝이상∼80㎝미만": "02",
"직경80㎝이상∼100㎝이하": "03"
}
},
"FW-00450": {
"next_serial": 4,
"slots": {
"직경40㎝이상∼60㎝미만": "01",
"직경60㎝이상∼80㎝미만": "02",
"직경80㎝이상∼100㎝이하": "03"
}
},
"FW-00454": {
"next_serial": 2,
"slots": {
"직경15㎝길이4m": "01"
}
},
"FW-00465": {
"next_serial": 3,
"slots": {
"중": "01",
"상등구조": "02"
}
},
"FW-00466": {
"next_serial": 5,
"slots": {
"식생매트설치": "01",
"복토": "02",
"간단구조": "03",
"보통구조": "04"
}
},
"FW-00467": {
"next_serial": 4,
"slots": {
"단끊기": "01",
"흙채우기(마대채우기)": "02",
"마대쌓기": "03"
}
},
"FW-00473": {
"next_serial": 3,
"slots": {
"사면": "01",
"연약지반": "02"
}
},
"FW-00474": {
"next_serial": 3,
"slots": {
"폭1.5m이하": "01",
"폭2.0m이하": "02"
}
},
"FW-00476": {
"next_serial": 3,
"slots": {
"무한궤도": "01",
"타이어": "02"
}
},
"FW-00477": {
"next_serial": 3,
"slots": {
"무한궤도": "01",
"타이어": "02"
}
}
}
}
File diff suppressed because it is too large Load Diff
+67 -14
View File
@@ -27,11 +27,14 @@ from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import ( # noqa: E402
assign_keys,
axis_role,
empty_registry,
assign_variant_keys,
format_key,
general_provision_roots,
load_policy,
path_names,
toc_edition,
variant_display,
variant_match,
toc_slot,
)
@@ -55,6 +58,7 @@ ADDED_FIELDS = (
"toc_edition",
"path_name",
"axis_role",
"variants",
)
@@ -223,11 +227,15 @@ def test_총칙_범위는_코드가_아니라_자료가_정함() -> None:
assert general_provision_roots("forest") == ("FP-01", "FP-02")
const = load_policy("const")
assert const["key_prefix"] == "CW"
assert const["settled"] is False
assert const["settled"] is True # 2026-09-17 안티그래비티 조사 · 브레인 확정
# 건설 뿌리는 **장 파일 이름**으로 적는다 — 뽑는 코드가 목차 코드로 바꿔 준다(랩탑 메인 `file_place`).
# ⚠ 바뀐 코드에는 부문 자리가 들어간다(`CP-01-01`) — 부문마다 장 번호가 1부터 다시 시작해서다.
assert const["general_provision_roots"] == ["01_공통부문/제1장_적용기준.md"]
assert [p["code"] for p in const["general_provision_pending"]] == ["CP-01-08", "CP-05-01"]
assert const["general_provision_roots"] == [
"01_공통부문/제1장_적용기준.md",
"01_공통부문/제8장_건설기계.md", # 산림 FP-02(소요재료·기계손료)와 짝
]
# 유지관리 제1장 「공 통」은 총칙이 아니라 **공종** — 미확정 목록이 비었다
assert const["general_provision_pending"] == []
# 뿌리를 바꿔 주면 구실도 따라 바뀐다 — 잣대가 자료에 있다는 증거
assert (
axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, roots=("FP-09",))
@@ -241,18 +249,63 @@ def test_건설_총칙_뿌리가_실제로_붙음() -> None:
(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 roles["general_provision"] == 112 # 적용기준 37 + 건설기계 75
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"
for root, count in (("CP-01-01", 37), ("CP-01-08", 75)):
under = [
it
for it in doc["work_items"]
if it["work_item_code"] == root or it["work_item_code"].startswith(root + "-")
]
assert len(under) == count
assert {it["axis_role"] for it in under} == {"general_provision"}
# 유지관리 제1장 「공 통」은 공종으로 남는다(총칙 아님)
공통 = next(it for it in doc["work_items"] if it["work_item_code"] == "CP-05-01")
assert 공통["axis_role"] != "general_provision"
def test_갈래에도_불변_열쇠가_있다(master: dict) -> None:
"""갈래 열쇠는 `FW-00105#01` — **글자가 아니라 번호**다(2026-09-17 브레인).
글자를 열쇠에 담으면 원문 자간·물결표가 다듬어지는 열쇠가 갈린다. 공종 열쇠와 같은 병이다.
"""
items = master["work_items"]
keys, pairs = [], []
for it in items:
for v in it.get("variants") or []:
assert re.fullmatch(r"\d{2}", v["variant_key"]), v
keys.append(f"{it['work_item_key']}#{v['variant_key']}")
pairs.append((v["name"], v["name_clean"]))
assert len(keys) == 332 # 갈래 수 — 헛단위 13-3 갈래 3 뺌(335 → 332 · 2026-09-17 · 장부 번호는 남음)
assert len(set(keys)) == len(keys) # 겹침 0
# 한 공종 안에서 번호는 1부터 빠짐없이
for it in items:
got = [v["variant_key"] for v in it.get("variants") or []]
assert got == [f"{i + 1:02d}" for i in range(len(got))], it["work_item_key"]
# 이름 칸 둘 — 원문은 손대지 않고, 다듬은 것이 따로
raw = [it.get("variant_keys") or [] for it in items]
assert [n for it in items for n in (it.get("variant_keys") or [])] == [p[0] for p in pairs]
assert sum(1 for a, b in pairs if a != b) == 14 # 자간·물결표가 다듬어진 줄
assert raw # 원문 목록은 그대로 남아 있다
def test_갈래_열쇠는_글자가_바뀌어도_안_흔들린다() -> None:
"""맞대는 꼴이 공백·물결표를 흡수한다 — 이름 다듬기가 들어와도 번호가 그대로다."""
reg = empty_registry()
node = {
"work_item_key": "FW-00105",
"sort_order": 1,
"variant_keys": ["1.11.5", "간 단"],
}
assign_variant_keys([node], reg)
before = [v["variant_key"] for v in node["variants"]]
# 원문을 다듬어도(물결표 바꿈 · 자간 정리) 같은 번호가 나와야 한다
node2 = {"work_item_key": "FW-00105", "sort_order": 1, "variant_keys": ["1.11.5", "간 단"]}
newly = assign_variant_keys([node2], reg)
assert [v["variant_key"] for v in node2["variants"]] == before
assert newly == [] # 새 번호를 내지 않는다
assert variant_match("간 단") == variant_match("간 단") == "간단"
assert variant_display("직경 60㎝이상 80㎝미만") == "직경 60㎝이상 80㎝미만"
def test_갈래_문자열이_열쇠라_흔들림을_못박음(master: dict) -> None:
+2 -2
View File
@@ -84,8 +84,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
# 2026-09-17 92 → 100 — 공종 축 산출 일곱을 이름표에 올림.
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 100
# 2026-09-17 92 → 101 — 공종 축 산출 일곱 + 갈래 열쇠 장부 표.
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 101
for entry in LABELS["files"]: # 이름이 실제로 붙음 · 줄 수도 이름표 셈과 같음
named = {t["key"]: t for t in entry["tables"]}
for t in files[entry["file_id"]]:
+13 -5
View File
@@ -102,7 +102,9 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
"steps": [{"code": "CP-01-01", "weight": "0.5"}], "steps_basis": "시험", "variant_keys": []},
{"work_item_code": "CP-01-01", "work_item_key": "CW-00002", "number": "1-1",
"name": "인력", "parent_code": "CP-01", "tables": [{"pum_table_id": "C0001"}],
"variant_keys": ["", ""]},
"variant_keys": ["", ""],
"variants": [{"variant_key": "01", "name": "", "name_clean": ""},
{"variant_key": "02", "name": "", "name_clean": ""}]},
],
} # fmt: skip
(folder / "const_work_item_master_2026-01-01.json").write_text(
@@ -111,7 +113,8 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
monkeypatch.setattr(work_items, "FOLDER", folder)
table = _get(client, "work_item_const")
assert [r["@id"] for r in table["rows"]] == ["CW-00002#갑", "CW-00002#을"] # 갈래마다 한 줄
# 갈래마다 한 줄 · 열쇠는 **글자가 아니라 번호**(2026-09-17 브레인 — 장부가 준다)
assert [r["@id"] for r in table["rows"]] == ["CW-00002#01", "CW-00002#02"]
assert {r["@key"] for r in table["rows"]} == {"CW-00002"}
assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음
assert table["rows"][0]["name"] == "토공 인력"
@@ -120,7 +123,7 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
res = client.put(
"/api/master-data/base-prices/work_item_const/CW-00002%23갑",
"/api/master-data/base-prices/work_item_const/CW-00002%2301",
json={"values": {"name": "바꿈"}},
)
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
@@ -158,7 +161,10 @@ def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestCl
client, "work_item_const", size=500, division="공통부문", axis_role="general_provision"
)
assert rules["total"] > 0
assert all(r["name"].startswith("공통부문 적용기준") for r in rules["rows"])
# 2026-09-17 건설 총칙 뿌리가 둘 — 적용기준(37) + 건설기계(75 · 산림 FP-02 와 짝)
assert all(
r["name"].startswith(("공통부문 적용기준", "공통부문 건설기계")) for r in rules["rows"]
)
role_names = {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}
# 표시 칸 — 이름은 잣대(`list_label` · 「공종 아님 · 규정·기준」) 한 곳
assert {r["axis_role"] for r in rules["rows"]} == {role_names["general_provision"]}
@@ -214,7 +220,9 @@ def test_갈래마다_한_줄이라_편_하나를_집는다(client: TestClient)
"1.0 이하", "1.11.5", "1.62.0", "2.12.5", "2.63.0",
"3.13.5", "3.64.0", "4.14.5", "4.65.0",
] # fmt: skip
assert rows[1]["@id"] == "FW-00105#1.11.5" and rows[1]["@key"] == "FW-00105"
# 열쇠에 갈래 **글자**를 담지 않는다 — 원문 물결표·자간이 다듬어지면 열쇠가 갈린다(브레인).
assert rows[1]["@id"] == "FW-00105#02" and rows[1]["@key"] == "FW-00105"
assert rows[1]["variant"] == "1.11.5" # 글자는 보일 이름 칸으로
# 갈래 없는 공종은 한 줄 · 열쇠에 # 없음
plain = _get(client, "work_item_forest", q="교목굴취(근원직경)")["rows"]
assert [(r["@id"], r["variant"]) for r in plain] == [("FW-00106", None)]