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
@@ -65,7 +65,7 @@
},
"work_item": {
"name_ko": "공종 축",
"summary": "**무엇을 하나** — 품셈 목차에서 일위대가가 붙는 잎 공종을 평평하게 편 목록(산림·건설). 읽기만."
"summary": "**무슨 일인가** — 품셈 목차가 세운 공종 나무를 잎으로 평평하게 편 목록(산림·건설). 줄의 열쇠는 `work_item_key`(FW·CW), 목차 번호는 칸이다. 읽기만."
}
},
"merged_tables": [
@@ -1366,7 +1366,7 @@
"sources": [
"work_item_master::work_items"
],
"rows_note": "잎 358 줄 · 갈래 펴면 618",
"rows_note": "잎 360 줄 · 갈래 펴면 620",
"columns": [
{
"key": "work_item_code",
@@ -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": {
@@ -1759,11 +1822,20 @@
{
"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": "잎 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,15 +1929,15 @@
}
},
"counts": {
"merged_tables": 16,
"merged_columns": 164,
"merged_tables": 17,
"merged_columns": 172,
"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 +2138,7 @@
"name_ko": "공종에 못 붙인 표",
"summary": "목차 어디에도 못 붙인 품셈 표.",
"shape": "list",
"rows": 17,
"rows": 15,
"columns": [
{
"key": "pum_table_id",
@@ -2194,24 +2266,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 +2279,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 +2311,12 @@
"name_ko": "목차 판(고시)",
"summary": "목차 번호가 묶인 고시 판. 값이 아니라 **판을 가리키는 표**다.",
"kind": "doc"
},
{
"key": "toc_duplicate_rows",
"name_ko": "목차 번호가 겹친 줄",
"summary": "목차가 같은 번호를 두 번 적었는데 본문 번호로도 못 가른 자리. 비어 있어야 정상 — 줄이 생기면 표가 딴 줄에 붙는다.",
"kind": "doc"
}
]
},
@@ -7059,6 +7131,11 @@
"unit": "",
"visible": true
},
"body_number": {
"name_ko": "본문 절 번호",
"unit": "",
"visible": true
},
"bond_codes": {
"name_ko": "쌓기 방식별 공종코드",
"unit": "",
@@ -8676,72 +8753,104 @@
}
},
"unknown": [],
"pending_merged_tables": {
"note": "**서버가 아직 안 내는 kind** 의 이름표를 미리 지어 둔 자리. `merged_tables` 는 서버 kind 와 한 짝이어야 해서(묵은 열 시험) 여기 둔다. 서버가 kind 를 내는 날 **그대로 `merged_tables` 로 옮기고** `test_master_labels_cover.py` 의 `MERGED_KEYS` 에 한 줄 더할 것.",
"items": [
"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": "건설공사 표준품셈 공종 축의 잎 — 일위대가가 붙는 단위. 산림과 같은 칸·같은 규칙이다. ⚠ 자리만 먼저 냄 — 뽑는 일은 랩탑 메인, 덜어낼 잣대는 `axis_policy.json`.",
"sources": [
"work_item_master_const::work_items"
],
"rows_note": "아직 안 뽑음 — 원문 표 2,192 · 목차 줄 2,000 안팎 예상",
"columns": [
{
"key": "work_item_code",
"name_ko": "공종코드",
"unit": "",
"visible": true,
"note": "품셈 목차 코드(FP-). ⚠ 불변 열쇠 아님 — 개정 때 번호가 밀림. 열쇠는 `@id`(FW-)."
},
{
"key": "number",
"name_ko": "품셈 번호",
"unit": "",
"visible": true
},
{
"key": "name",
"name_ko": "이름",
"unit": "",
"visible": true,
"note": "뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」) — 잎 이름만으론 겹침."
},
{
"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` 일 때만 — 발파암 절취 0.1 · 깍기 0.9 · 집토 1."
},
{
"key": "effective_date",
"name_ko": "적용 시작일",
"unit": "",
"visible": true
}
]
"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-17T12:15:05+09:00",
"generated_at": "2026-09-17T13:27:58+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": "762f18dc42a4e6d5512287127e9a21ee0348425aad62b040e29a97be2ec00cdd",
"size_bytes": 954642
"sha256": "f7658b47d89889207d079f1050cea87f592b97d34995f69272337b064a9d2fb1",
"size_bytes": 956887
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-17T13:27:59+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": "const_work_item_master_2026-01-01.json",
"sha256": "cbd4174707c4205f4b0ded703ed15d8edaea0c41dbf6077683897afaa2a90cd9",
"size_bytes": 3752560
},
{
"file": "const_form_undetermined_2026-01-01.json",
"sha256": "dbb4f245cb9aee71325ab73c1325ee5f06ff19e0e7527de2053d08a7eb16282e",
"size_bytes": 486761
},
{
"file": "const_basis_missing_2026-01-01.json",
"sha256": "0ae14342e37ba5f394c36cc4dd8cea1b505b13d6520e0a12979c21676d6ffcdb",
"size_bytes": 74228
}
]
}
@@ -65,32 +65,45 @@
"name_ko": "건설 공종",
"settled": false,
"source": "resources/data_cost_input_value/pum_const_2026.json",
"toc_source": "⚠ 목차표가 없다 — 장 파일 46개(`source_file` 칸)가 유일한 계층 근거다",
"toc_source": "합본 원문 머리 「목 차」 글줄 — 목차표가 없어 랩탑 메인이 글에서 읽음(부문 5 · 장 45 · 1,367줄)",
"code_prefix": "CP",
"key_prefix": "CW",
"code_shape": "CP-<부문2>-<장2>-<절…>",
"code_shape_why": "⚠ **부문마다 장 번호가 1부터 다시 시작한다** — 01_공통부문 제1장은 적용기준, 02_토목부문 제1장은 도로포장공사다. 장 번호만으로 코드를 지으면 부문이 다른 공종끼리 덮는다. 산림에서 목차 번호 겹침 둘(12-17-2·12-24-1)이 실제로 서로 덮고 있었다.",
"code_shape_why": "⚠ **부문마다 장 번호가 1부터 다시 시작한다** — 공통 1장은 적용기준, 토목 1장은 도로포장공사다. 부문 자리가 없으면 부문이 다른 공종끼리 덮는다(산림에서 목차 번호 겹침 둘이 실제로 덮고 있었다). ⇒ **총칙 뿌리도 부문 자리까지 적는다.**",
"general_provision_roots": [
"01_공통부문/제1장_적용기준.md"
"CP-01-01"
],
"general_provision_basis": "산림 FP-01 적용기준과 짝. 단위표준·토질·할증 같은 기준표 46개.",
"general_provision_basis": "CP-01-01 공통부문 제1장 적용기준 — 줄 37 · 표 46. 산림 FP-01 적용기준과 짝. 단위표준·토질·할증 같은 기준표.",
"general_provision_pending": [
{
"chapter": "01_공통부문/제8장_건설기계.md",
"root": "CP-01-08",
"chapter": "공통부문 제8장 건설기계",
"rows": 75,
"tables": 351,
"why": "산림 FP-02 「소요재료 및 기계손료」와 짝으로 보이나, 기계 시공능력·손료 파라미터라 공종으로 볼 여지도 있음. ⬜ 안티그래비티 조사 대기.",
"already_extracted": "C0426·C0427 은 `resources/data_machine_productivity/` 로 이미 꺼냈음"
},
{
"chapter": "05_유지관리부문/제1장_공통.md",
"root": "CP-05-01",
"chapter": "유지관리부문 제1장 공통",
"rows": 37,
"tables": 40,
"why": "이름이 「공통」 — 기준표 장인지 공종 장인지 원문 확인 필요. ⬜ 안티그래비티 조사 대기."
"why": "이름이 「공 통」 — 기준표 장인지 공종 장인지 원문 확인 필요. ⬜ 안티그래비티 조사 대기."
}
],
"known_defects": [
"표 2,192 중 883(40 %)이 절 이름이 어긋남 — 파서가 글 속 제목을 못 잡음(PLAN 데스크탑서브 「빠진 표 일곱」 절)",
"`section` 이 절 번호가 아닌 표 289 — 그 가운데 11 은 값이 박힘"
]
"형태 미판정 881 / 2,192 — B09 표 읽기가 산림 표로만 검증됨",
"밑수 미확보 447 — 곱하면 안 되는 줄이라 받는 쪽이 가려야 함",
"갈래 키 비어 있음 — 랩탑 메인이 뒤로 미룸"
],
"counts_2026": {
"rows": 1367,
"work_item": 997,
"group": 270,
"general_provision": 0,
"empty": 100,
"note": "총칙 표시 전 셈(랩탑 메인 첫 산출). 뿌리를 붙이면 `general_provision` 으로 옮겨 간다 — 줄이 줄지는 않는다."
}
}
}
}
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
@@ -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
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 공종 축 건설
)
+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"]