"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (B08 일감 6 · PLAN 8-6·8-8·8-15). 식을 발명하지 않는다 — 실무 원본을 옮긴다 울진 설계원본 `5. 구조도(기번3).xlsx` 에 구조물 31종의 계산식이 **살아 있는 수식**으로 남아 있다(PLAN 8-15). 여기 옮긴 것은 그 식이며, 식 안에 상수로 박혀 있던 값 (돌 뒷길이 0.45 · 공극률 0.77 · 돌 비중 2.65 · 고임돌 0.15 등)은 **계수표로 뺐다**. 그래야 뒷길이가 바뀔 때 식을 안 고친다 — 실무 방식의 약점을 여기서 고친다. 치수 정본은 하나다 (PLAN 8-6 ② 필수 조건) 전개식은 **저장된 구조물 제원**(`structures.json` 의 `type_id`·`options`)을 읽어 계산한다. 자기 치수표를 따로 들지 않는다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다. ⚠⚠ 이중계상 셋 — 이 파일이 지켜야 할 규칙 ㉢ **배합을 분해하지 않는다.** 산출물은 `콘크리트 ㎥` · `모르터 ㎥` 에서 **멈춘다**. 시멘트·모래·자갈로 쪼개는 것은 B09 일위대가 몫이다. 양쪽이 쪼개면 시멘트가 두 배가 된다. 실무 원단위 라이브러리에 배합이 이미 분해돼 있어도 **그 줄은 버린다**(PLAN 8-8 ②). `verify_no_mix_components()` 가 이 규칙을 코드로 지킨다. ㉠ **할증을 붙이지 않는다.** 여기 값은 전부 할증 **전**이다. 할증은 자재총괄 한 곳뿐(PLAN 8-7). · **터파기·되메우기·잔토는 토공으로 합산된다.** 내역 줄의 실체는 작업 공종 (`돌쌓기(찰) H=1.5 · 70m`)이고 그 전개인 터파기는 토공 대분류로 합쳐진다 (울진 토적집계 D12~D14 실증). 둘 다 내역에 올리면 이중계상이다 — 그래서 성분마다 `destination` 을 달아 어디로 갈 값인지 표시한다. ⚠ 공제 규칙 (품셈 1-2-1 원문) 「말뚝머리, 볼트 구멍, 모따기ㆍ물구멍, 이음줄눈 간격, 포장 1개소당 0.1 ㎡ 이하 구조물 자리, 리벳 구멍, **철근콘크리트 중의 철근** 등」은 **공제하지 않는다.** 치수를 곧이곧대로 빼면 실무값과 어긋난다. 전개식에서 빼는 것은 **관 통과 단면**처럼 실제로 비어 있는 자리뿐이다. """ from __future__ import annotations import json import math from pathlib import Path from functools import lru_cache from dataclasses import dataclass, field from typing import Any, Iterable from common_util.common_util_structure_face_role import structure_face_role from common_util.common_util_excavation import ( BASIS_WALL, WALL_BLINDING_DEPTH_M, WALL_FOUNDATION_DEPTH_M, WALL_TRENCH_CLEARANCE_M, wall_trench_area_m2, wall_trench_width_m, ) from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( BASIS_DERIVED, BASIS_OBSERVED, ObservedUnitTable, billing_of, expand_observed, load_observed_table, ) from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status from common_util.common_util_quantity_spread import spread_by_unit # ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── # 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. # ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). #: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다. #: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다. #: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`. STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { 25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None}, 30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None}, 35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575}, 45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88}, 55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10}, 60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None}, 75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None}, } DEFAULT_BACK_LENGTH_CM = 45 #: ⑤ 돌 중량이 어디서 온 값인가 (2026-09-08 사용자 확정 2차 ⑤ 「DB 확인 → 없으면 현재 유지, #: 나중에 프로그램에서 확인할 수 있게 근거를 남길 것」). #: ⚠ **품셈·교본에는 돌중량표가 없다** — 지식DB 전수 확인 결과 이 값이 나오는 곳은 #: 실무 원단위 라이브러리 한 곳뿐이다(기술문서에는 그 라이브러리를 인용한 줄만 있음). #: 그래서 값을 바꾸지 않고 **어디서 온 값인지**를 줄마다 달아 화면에서 되짚게 한다. STONE_WEIGHT_SOURCE = "uljin_library" STONE_WEIGHT_BASIS = ( "⚠ 실무 관측값 — 울진 소광 원단위 라이브러리 「돌뒷길이별 원단위표」(35·45·55㎝만 있음)." " 품셈·교본에는 돌중량표가 없음" ) #: ⭐ 사용자 확정 5차 큰 것 7 — **돌 무게는 계산식**: `뒷길이 × 0.77 × 2.65`. #: 정본 여섯 탭이 모두 그 식을 그대로 적어 둔다(「2.09 × 0.45 × 0.77 × 2.65 ton/m3」). #: ⚠ **야면석은 그 식이 안 맞는다** — 공극이 커 ㎥당 무게가 달라 원문이 값을 직접 준다 #: (35→0.575 · 45→0.880 · 55→1.100 ton/㎡). 그래서 **종류로 갈래를 둔다.** #: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다. STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫 STONE_DENSITY_TON_M3 = 2.65 # 석재 비중 #: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우도 이쪽(지금까지 쓰던 값이 그것). STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"}) STONE_WEIGHT_FORMULA_BASIS = ( "⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값" ) #: 물구멍 칸 — 2026-09-09 에 등록부에 생겼다(랩탑 메인 `0b0763ed`). **빈 칸이 기본**이다. #: ⚠ 기본 Ø50 은 **국가기준**이고 2㎡당 1개소는 실무 관측값이다(법은 「2~3㎡당 1개소 이상」). WEEP_HOLE_DIAMETER_KEY = "weep_hole_diameter_mm" WEEP_HOLE_AREA_KEY = "weep_hole_area_m2" WEEP_HOLE_DIAMETER_DEFAULT_MM = 50 def weep_hole_spec(options: dict[str, Any]) -> tuple[float, int, str]: """(개소당 벽면적 ㎡, 관 지름 ㎜, 근거). 안 정하면 기본으로 서고 그 사실이 근거에 남는다.""" area = _num(options.get(WEEP_HOLE_AREA_KEY), 0.0) diameter = _num(options.get(WEEP_HOLE_DIAMETER_KEY), 0.0) area_given = area > 0 dia_given = diameter > 0 if not area_given: area = STONE_MASONRY["weep_hole_area_m2"] if not dia_given: diameter = WEEP_HOLE_DIAMETER_DEFAULT_MM basis = ( f"{area:g}㎡당 1개소 · Ø{int(diameter)}" + ("" if area_given else " · ⚠ 면적 안 정함 — 실무 관측 2㎡(법은 2~3㎡당 1개소 이상)") + ("" if dia_given else " · ⚠ 지름 안 정함 — 국가기준 Ø50") ) return area, int(diameter), basis def stone_weight_per_m2( back_cm: int, kind: str, observed: float | None ) -> tuple[str, float | None, str, str]: """(줄 이름, ton/㎡, 근거, source) — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다. 돌쌓기·골막이가 같은 규칙을 쓰도록 여기 한 벌만 둔다. """ if kind in STONE_WEIGHT_OBSERVED_KINDS: basis = ( f"× {observed} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}" if observed is not None else "" ) return kind, observed, basis, STONE_WEIGHT_SOURCE ton = back_cm / 100.0 * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3 basis = ( f"× {ton:.3f} ton/㎡ = 뒷길이 {back_cm / 100.0:g} × {STONE_PACKING_RATIO}" f" × {STONE_DENSITY_TON_M3}" + (f" · {kind}" if kind else " · ⚠ 돌 종류를 안 골랐음") + f" · {STONE_WEIGHT_FORMULA_BASIS}" ) return kind or "돌", ton, basis, "" #: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」. #: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면 #: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다. #: 정본 `04.구조도(기슭막이).xls` **여섯 탭 전부** 상 0.30 · 하 0.45 로 같다 #: (H=1.0~3.0 · 메/찰 · 기초유/버림 전수 확인, 2026-09-09). #: ⓘ 소광리는 같은 식에 **0.30 / 0.60** 을 쓴다(그래서 H=2.0 에서 0.900). 문서마다 폭이 #: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다. STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45} #: ⚠⚠ 정본 전수 확인이 **H=1.0~3.0** 까지다 — 그보다 높은 벽은 **근거가 없다.** #: 그리고 그 자리에서 품셈 13-4-4 [주]⑨ <뒤채움 조약돌 두께> 범위와 **갈린다**: #: H 3.0 두께 0.359 (품셈 0.33~0.58 안) · H 3.5 0.354 (품셈 0.40~0.70 **아래**) #: 까닭은 이 식의 두께(= 막자갈 ÷ 비탈면적)가 `0.375 ÷ √(1+경사²)` 로 거의 붙박이인데, #: 품셈 표는 직고와 함께 두꺼워지기 때문이다 — **방향이 반대다.** #: ⚠ 교본 7-3 은 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고** #: 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 즉 **높은 벽이 실제로 설 수 있다.** #: 값을 눅이거나 막지 않고 **그 사실을 사유로 드러낸다**(임의 확정 금지). STONE_BACKFILL_VERIFIED_HEIGHT_M = 3.0 #: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다. #: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로. STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)" #: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. #: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. #: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이 #: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다. STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" STONE_KIND_PREFIX = "stone_kind_" STONE_KIND_OPTION = "stone_kind" @lru_cache(maxsize=1) def load_stone_kind_table() -> dict[str, Any]: """돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다.""" folder = STONE_KIND_DIR if not folder.is_dir(): return {} files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json")) if not files: return {} try: return json.loads(files[-1].read_text(encoding="utf-8")) except (OSError, ValueError): return {} #: ⑨ 계수를 **어느 열로 볼 것인가** (2026-09-08 사용자 확정 2차 ⑨). #: 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)이고, 「실무 관행」으로 두면 #: 건설품셈 참고자료 한 벌(깬돌 계열 0.15 · 0.20)로 덮어쓴다. #: ⚠ **빈 칸은 「안 정함」**이고 그때 기본(품셈)으로 선다 — 등록부 default 는 비어 있다. #: 기본값을 등록부가 아니라 여기가 갖는 것은 `face_slope_ratio` 와 같은 결이다. COEFF_BASIS_KEY = "stone_coeff_basis" COEFF_BASIS_PRACTICE = {"실무 관행", "실무관행", "practice", "관행"} def wants_practice_coefficients(options: dict[str, Any]) -> bool: """계수를 실무 관행 열로 볼 것인가. 빈 칸이면 **아니오**(= 품셈 열).""" return str(options.get(COEFF_BASIS_KEY) or "").strip() in COEFF_BASIS_PRACTICE def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]: """(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다. ⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다. ⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다. """ table = load_stone_kind_table() key = str(back_cm) kind = str(options.get(STONE_KIND_OPTION) or "").strip() if not table: return {}, "" if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]: return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다") practice = wants_practice_coefficients(options) if not kind or practice: fallback = table.get("fallback") or {} message = ( f"「{kind}」이지만 계수를 **실무 관행 열**(건설품셈 참고자료)로 덮어썼습니다 —" " 기본은 품셈 열입니다(확정 ⑨)" if practice and kind else str(fallback.get("message") or "") ) return { "wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key), "fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key), "backfill_ratio": fallback.get("backfill_ratio_of_back_length"), "kind": "", }, message if kind not in (table.get("kinds") or []): return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다" wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key) fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key) ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind) note = "" if wedge is None: note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다" return { "wedge_stone_m3_per_m2": wedge, "fill_concrete_m3_per_m2": fill, "backfill_ratio": ratio, "kind": kind, }, note # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. #: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09). #: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26: #: 골막이 평균두께 = {(ℓ3 + 0.1×H) + (ℓ3 + 0.4×H)} ÷ 2 #: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H − 1) #: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을 #: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어 #: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는** #: 결함이 조용히 남는다. #: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** #: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) · #: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**. STONE_MASONRY = { # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). # 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다 # (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가 # **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고 # 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다. # ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가 # 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다. "sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리 # ⭐ 2026-09-09 확정 2차 ② — **실무 구조물도 식**으로 갈았음(랩탑 보조가 소광리 원본 # 네 탭에서 찾아 실측과 소수점까지 대조). # 상부 두께 = 뒷길이(ℓ3) + 0.30 # 하부 두께 = 상부 + 0.30 × (H − 1.0) # ⚠ **옛 식에는 뒷길이가 없었다**(0.45 + 0.10·H / 0.45 + 0.40·H) — 뒷길이를 35 로 # 바꿔도 두께가 안 바뀌어 돌 부피·막자갈·터파기가 다 같은 값으로 섰다. # ⚠ 정본 xls 의 「상부 0.75 · 하부 0.90」은 **H=1.5 규칙값을 다른 높이 탭에 복사**한 # 흔적이라 그대로 쓰면 H=1.5 밖에서 틀린다. # ⚠ 돌조공(H=0.5 · 0.55/0.65)은 이 규칙 밖 — 다른 시설이라 여기서 다루지 않는다. "thickness_top_add_m": 0.30, # 상부 = 뒷길이 + 0.30 "thickness_slope_per_m": 0.30, # 하부 = 상부 + 0.30 × (H − 1.0) "thickness_height_base_m": 1.0, # 그 식의 기준 높이 # ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다. # 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다. "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 # ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m · # 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을 # 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다. "weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) # ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다 # (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다). # 여기서 다시 두면 그림과 물량이 갈린다. } # 성분이 어디로 가는가 — 이중계상을 막는 표시. # `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증) # `material` = 자재총괄로 감(할증은 거기서 한 번만) # `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해) #: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭). #: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고 #: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」. #: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의 #: 폭과 동일**하게 한다」. #: ⭐ 그 잡석다짐 폭이 **정해졌다** — 「기준 구조물에서 유도, 같은 값을 공유」 #: (2026-09-08 사용자 확정 2차 ⑪ 「기슭막이면 **하단 길이** × 종단방향 폭 × 높이」). #: ⇒ 벽에서 **하단 길이 = 하부 두께**다. 종전의 「터파기 폭 잠정」을 그 값으로 바꾼다. #: ⚠ **기초잡석(품셈 12-25) 물량은 아직 못 센다** — 폭은 이제 있으나 **두께가 어느 #: 원문에도 없다**(품셈은 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 #: **다른 구조물 값을 옮겨 쓰지 않고** 사유로 드러낸다. #: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②). #: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서 #: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가 #: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다. #: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다. STONE_SUPPLY_KEYS = ("stone_supply", "stone_source") STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"} #: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도 #: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다). #: #: 채집석 공제는 사토에서 한 번만 뺀다. #: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, #: 빼는 자리는 유토곡선의 사토뿐이다 — #: 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. #: #: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번 #: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다. COLLECTED_STONE_KEY = "collected_stone_deduction_m3" #: ⑩ 채움 콘크리트 강도 (2026-09-08 사용자 확정 2차 ⑩ 「기본 210, 고를 수 있게」). #: ⚠ **빈 칸은 「안 정함」**이고 그때 210 으로 선다 — 등록부 default 는 비어 있다. #: 180 은 국가기준 하한(돌쌓기 전용 값)이라 고르면 그 값이 이긴다. FILL_CONCRETE_MPA_KEY = "fill_concrete_mpa" FILL_CONCRETE_MPA_DEFAULT = "210" FILL_CONCRETE_MPA_CHOICES = ("180", "210") def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]: """(강도, 근거). 안 정했으면 기본 210 이고 그 사실이 근거에 적힌다.""" value = str(options.get(FILL_CONCRETE_MPA_KEY) or "").strip() if value in FILL_CONCRETE_MPA_CHOICES: return value, f"강도 {value} — 저장 제원에서 고른 값" if value: return ( FILL_CONCRETE_MPA_DEFAULT, f"강도 {FILL_CONCRETE_MPA_DEFAULT} — ⚠ 저장값 「{value}」은 180·210 중에" " 없어 기본으로 섬", ) return ( FILL_CONCRETE_MPA_DEFAULT, f"강도 {FILL_CONCRETE_MPA_DEFAULT} — 안 정해 기본값(확정 ⑩)", ) BLINDING_THICKNESS_M = 0.10 #: 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」. #: ⚠ 품셈 12-25 는 **㎥당 품만** 주고 두께를 정하지 않는다(전 출처 소진). 폭은 **버림 폭과 #: 같고**(KCS 34 50 05 「버림 콘크리트의 폭은 잡석다짐의 폭과 동일」) 그 폭은 확정 ⑪ 로 #: 하단 길이다. ⇒ **기초잡석 = 버림 × (잡석두께 ÷ 버림두께)** 로 나온다 — 같은 폭·연장이라 #: 두께 비만 곱하면 된다. 관측 원단위로 오는 구조물(옹벽)도 같은 식으로 선다 #: (버림 0.15㎥/m ÷ 0.1 = 폭 1.5m ⇒ 잡석 0.30㎥/m — 확정 3차 ② 의 그 값). RUBBLE_BASE_THICKNESS_M = 0.2 RUBBLE_BASE_NAME = "기초잡석" #: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭). #: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다. BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding") DESTINATION = { "터파기": "earthwork", "되메우기": "earthwork", "잔토처리": "earthwork", "돌쌓기": "unit_price", "돌붙임": "unit_price", "깬돌": "material", "야면석": "material", "고임돌": "material", "막자갈": "material", # ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시). # ⚠ 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다(서브 일위대가도 재료 0원). # 그래서 자재로 안 보내면 **재료비가 통째로 빠진다** — B09 매김에서 드러난 자리다. # ⚠ 배합을 분해하지 않는 규칙(㉢)은 그대로다 — 「콘크리트 ㎥」에서 멈춘다. "콘크리트": "material", "채움콘크리트": "material", "버림콘크리트": "material", "모르터": "unit_price", "거푸집": "unit_price", "물구멍관": "material", # ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은 # `material` 만 모으므로 여기 섞이지 않는다. "채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약) # ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에 # 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에 # 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다. # ⚠ 「석적」은 정본에 없고 소광리 시트에만 있으나 **확정 5차로 세운다** — 같은 # 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다. "입적": "reference", "석적": "reference", # 돌 종류를 고르면 그 이름으로 줄이 선다 — 자재총괄이 이름으로 찾으므로 넷 다 둔다. "야면석·호박돌": "material", "깬잡석": "material", "견치돌": "material", # 종류를 안 고른 경우의 이름 — 정본 계산표 줄 이름 그대로다(「돌 ℓ3=45cm」). "돌": "material", # 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다. # 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다. RUBBLE_BASE_NAME: "unit_price", } # ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. # ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로 # 오탐된다 — 개발 중 실제로 걸렸던 자리다. MIX_COMPONENTS = frozenset( {"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"} ) @dataclass(slots=True) class Component: """전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다.""" name: str unit: str amount: float destination: str basis: str = "" # ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). # 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다. basis_kind: str = BASIS_DERIVED source: str = "" #: 규격 — 같은 이름이라도 단가가 갈리는 값(채움 콘크리트 강도 등). 받는 쪽이 읽는다. spec: str = "" @dataclass(slots=True) class StructureQuantity: """구조물 하나의 원단위 전개 결과.""" structure_id: str | None type_id: str name: str length_m: float = 0.0 height_m: float = 0.0 # 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계). start_m: float | None = None end_m: float | None = None # 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다. # 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나). options: dict[str, Any] = field(default_factory=dict) components: list[Component] = field(default_factory=list) notes: list[str] = field(default_factory=list) #: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인 #: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증). #: 비어 있으면 종전대로 「m · 연장」으로 선다. billing_unit: str = "" billing_quantity: float = 0.0 #: 구조물이 놓인 자리의 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`)와 그 판정 근거. #: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다. ground_type: str | None = None ground_type_basis: str = "" #: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다. SLOPE_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" SLOPE_PREFIX = "masonry_slope_" #: 사용자가 덮어쓰는 칸 — 값이 있으면 **그 값이 이긴다**(확정 ⑨). FACE_SLOPE_KEYS = ("face_slope_ratio",) #: 성토/절토는 **측점 단면유형(`design.section_mode`) + 설치 측**으로 판정한다 #: (`common_util_structure_face_role` 한 벌). 못 가르는 자리(`both_cut` + 「자동」)만 남아 #: 그때 쓸 칸을 여기 한 곳에 둔다 — **눅여 쓰는 것이 아니라 종전값으로 서고 사유를 적는다.** DEFAULT_SLOPE_FACE = "성토" LEGACY_FACE_SLOPE_RATIO = 0.3 @lru_cache(maxsize=1) def load_slope_table(path: str | None = None) -> dict[str, Any]: """표준경사표. 없으면 빈 표 — 그때는 종전 기본값으로 돈다(값을 지어내지 않는다).""" target = Path(path) if path else None if target is None: files = sorted(SLOPE_DIR.glob(SLOPE_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else [] target = files[-1] if files else None if target is None or not target.is_file(): return {} return json.loads(target.read_text(encoding="utf-8")) def face_slope_ratio( options: dict[str, Any], *, wet: bool, height_m: float, face: str | None = None, face_reason: str = "", table: dict[str, Any] | None = None, ) -> tuple[float, str]: """(기울기 1:n 의 n, 근거 문구). ⚠ **사용자가 정했으면 그 값이 이긴다**(확정 ⑨). 안 정했으면 품셈 13-4-4 [주]⑪ <표준경사>에서 **직고 · 메/찰 · 성토/절토** 셋으로 고른다. ⚠ 자동으로 고른 값을 **저장에 박지 않는다** — 「정한 적 없음」과 「0.3 으로 정함」이 구별돼야 한다(오늘 패널 접힘에서 지킨 것과 같은 자리). ⚠ 경계값은 **앞 칸**이다 — 원문이 `∼3` 이라 직고 3.0m 은 `~3` 칸이다. """ for key in FACE_SLOPE_KEYS: given = _num(options.get(key), 0.0) if options.get(key) is not None and given > 0: return given, f"사용자 지정 1:{given:g}" found = table if table is not None else load_slope_table() steps = found.get("steps_m") or [] bond = "찰쌓기" if wet else "메쌓기" # ⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다. 못 가르면 # `None` 이 오는데, **성토로 눅이지 않는다** — 임의값이 금액으로 굳으면 안 된다. # 그때는 종전값으로 서되 **왜 못 갈랐는지**를 근거에 적는다. if not face: why = face_reason or "성절토를 가를 근거 없음" return ( LEGACY_FACE_SLOPE_RATIO, f"⚠ {why} — 표준경사표를 못 골라 종전값 1:{LEGACY_FACE_SLOPE_RATIO} 로 섰음", ) row = ((found.get("table") or {}).get(bond) or {}).get(face) if not row: return LEGACY_FACE_SLOPE_RATIO, f"표준경사표를 못 읽어 종전값 1:{LEGACY_FACE_SLOPE_RATIO}" index = len(steps) for i, limit in enumerate(steps): if height_m <= float(limit): index = i break ratio = float(row[min(index, len(row) - 1)]) label = f"직고 {height_m:g}m" + (f" ≤{steps[index]:g}m" if index < len(steps) else " 7m 초과") note = f"품셈 13-4-4 [주]⑪ 표준경사 · {bond} {face} · {label} → 1:{ratio:g}" if face_reason: note += f" · {face_reason}" return ratio, note def is_collected_stone(options: dict[str, Any]) -> bool: """이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②).""" for key in STONE_SUPPLY_KEYS: raw = options.get(key) if raw is None: continue if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED: return False return True def wants_blinding(options: dict[str, Any]) -> bool: """버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭). 「안 넣음」·「제외」·`false` 로 적혀 있을 때만 뺀다 — 빈 칸을 「빼기」로 읽으면 저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다. """ for key in BLINDING_OPTION_KEYS: raw = options.get(key) if raw is None: continue text = str(raw).strip().lower() if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}: return False return True def _blinding_component( base_width_m: float, length_m: float, options: dict[str, Any], width_basis: str = "", ): """버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`. 폭은 **잡석다짐 폭**(KCS 34 50 05)이고, 그 값은 확정 ⑪ 로 **구조물 하단 길이**다. 부르는 쪽이 그 폭과 까닭(`width_basis`)을 함께 넘긴다. """ if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0: return None basis = ( f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m" " · 두께는 KDS 44 90 00(100㎜) · 폭은 잡석다짐 폭(KCS 34 50 05)" ) if width_basis: basis = f"{basis} = {width_basis}" return Component( "버림콘크리트", "㎥", base_width_m * length_m * BLINDING_THICKNESS_M, DESTINATION["버림콘크리트"], basis, ) #: 기초잡석(품셈 12-25)이 아직 못 서는 까닭 — **폭은 있고 두께가 없다.** RUBBLE_BASE_BLOCKED = ( "기초잡석(12-25) 물량이 안 섬 — 폭은 하단 길이 {width:.2f}m 로 섰으나 **두께가 원문에 " "없음**(품셈 12-25 는 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 " "다른 구조물 값을 옮겨 쓰지 않음" ) #: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). #: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** #: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. #: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다). BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") def _back_length(options: dict[str, Any]) -> int: """저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45. ⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** — 접으면 다른 규격 계수가 조용히 돈다. """ for key in BACK_LENGTH_KEYS: raw = options.get(key) if raw is None: continue try: value = int(float(raw)) except (TypeError, ValueError): continue # ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가 # 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). # 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다. return value return DEFAULT_BACK_LENGTH_CM def _num(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback #: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 2차 ② 후반 #: 「사용자가 값을 바꿀 수 있게」). 야면석 계수·채움 강도와 같은 자리다. WALL_THICKNESS_KEYS: tuple[str, str] = ("thickness_top_m", "thickness_bottom_m") def wall_thickness( options: dict[str, Any], *, back_cm: float, height_m: float ) -> tuple[float, float, str]: """(상부 두께 m, 하부 두께 m, 근거 문구) — **사용자가 넣은 값이 식을 이긴다.** ⚠ 빈 칸은 「안 정함」이다 — 자동으로 나온 값을 저장에 박지 않는다(`face_slope_ratio` 와 같은 태도). 상부만 넣으면 하부는 **그 상부 위에서** 식을 이어 간다 — 벽 모양 (아래로 벌어지는 기울기)을 사용자 값 위에 그대로 얹는 것이 자연스럽다. """ def given(key: str) -> float | None: raw = options.get(key) if raw in (None, ""): return None try: value = float(raw) except (TypeError, ValueError): return None return value if value > 0 else None constants = STONE_MASONRY back_m = back_cm / 100.0 top_given = given(WALL_THICKNESS_KEYS[0]) top = top_given if top_given is not None else back_m + constants["thickness_top_add_m"] rise = constants["thickness_slope_per_m"] * max( height_m - constants["thickness_height_base_m"], 0.0 ) bottom_given = given(WALL_THICKNESS_KEYS[1]) bottom = bottom_given if bottom_given is not None else top + rise top_basis = ( f"상부 {top:.2f}(사용자 입력)" if top_given is not None else f"상부 {top:.2f}(뒷길이 {back_cm:g}㎝ + {constants['thickness_top_add_m']:g})" ) bottom_basis = ( f"하부 {bottom:.2f}(사용자 입력)" if bottom_given is not None else ( f"하부 {bottom:.2f}(상부 + {constants['thickness_slope_per_m']:g}" f"×(H−{constants['thickness_height_base_m']:g}))" ) ) return top, bottom, f"실무 구조물도 식 — {top_basis} · {bottom_basis}" #: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인). #: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가 #: 「뒷길이 45㎝」 계수로 돌던 자리다. BOULDER_DIAMETERS = ("40~60", "60~80", "80~100") #: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. #: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다. BOULDER_MASONRY = { # ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것. # 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다. "sheet_check_factor_at_0_3": 1.04, # ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다 # (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다). # 여기서 다시 두면 그림과 물량이 갈린다. } def _boulder_diameter(options: dict[str, Any]) -> str | None: """저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**.""" raw = options.get("stone_cm") text = str(raw).strip() if raw is not None else "" return text if text in BOULDER_DIAMETERS else None def boulder_masonry( height_m: float, length_m: float, options: dict[str, Any] ) -> tuple[list[Component], list[str]]: """큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다. ⚠ 재료 원단위가 품셈에 없다 — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. 돌쌓기(13-4)처럼 「뒷길이별 돌중량·고임돌」 표가 **없으므로** 큰돌·고임돌·막자갈을 내지 않는다. 대신 **왜 못 내는지**를 알림으로 남긴다. ⚠ 고임돌·채움콘크리트 품은 **품에 포함**돼 있다(13-6-1 [주]①·13-6-2 [주]①) — 따로 세우면 이중계상이다. ⚠ 뒤채움(조약돌)은 **13-3 을 적용**한다(13-6 [주]⑧) — 이 표에서 찾지 않는다. 두께는 13-4-3 [주]⑨ 를 준용하는데 **직고별 범위값**(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정한다. 미확보로 둔다. """ notes: list[str] = [] if height_m <= 0 or length_m <= 0: return [], ["높이·연장이 없어 전개하지 않음"] diameter = _boulder_diameter(options) if diameter is None: return [], [ "큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다" ] # ⚠ 큰돌쌓기는 **표준경사표 대상이 아니다** — 교본이 「1:0.3 **이상**」으로만 두었다. # 저장 제원에 값이 있으면 그것을 쓰고, 없으면 하한 0.3 으로 둔다. slope_ratio = _num(options.get("face_slope_ratio"), 0.3) face_area = height_m * length_m # 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘). masonry_area = face_area * math.hypot(1.0, slope_ratio) components = [ Component( "큰돌쌓기", "㎡", masonry_area, DESTINATION["돌쌓기"], f"정면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)", ) ] # 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다. # ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로 # 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다. upper_cm = float(str(diameter).split("~")[-1]) thickness = upper_cm / 100.0 # 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」. blinding = _blinding_component( thickness, length_m, options, f"하단 길이(직경 위끝) {thickness:.2f}m — 확정 ⑪ 준용", ) if blinding is not None: components.append(blinding) components.extend( _trench_components( height_m, length_m, thickness, options, notes, "⚠ 큰돌쌓기 터파기 폭 규정이 품셈에 없어 돌쌓기 방식을 준용", ) ) notes.append( "재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 " "돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음" ) notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음") notes.append( "뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 " "직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보" ) # ⚠ **채워져 있으면 그 사유를 붙이지 않는다**(2026-09-09). `bond` 는 레지스트리에 이미 # 있는 칸인데 조건 없이 붙이고 있어, 값을 고른 뒤에도 「못 고름」이 뜨고 있었다. # ⚠ 얻는 것은 **노무 품 갈래뿐**이고 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다 — # 그래도 고치는 까닭은 **사유가 거짓이면 사유 칸 전체를 못 믿게 되기 때문**이다. if not str(options.get("bond") or "").strip(): notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름") return components, notes def _trench_components( height_m: float, length_m: float, thickness: float, options: dict[str, Any], notes: list[str], extra_basis: str = "", ) -> list[Component]: """터파기·되메우기·잔토 — **공용 단면 함수 한 벌**(`common_util_excavation`). ⚠ 여기서 식을 다시 짜지 않는다 — 화면(B06 횡단도)이 그리는 단면과 같은 코드라야 그림과 물량이 안 갈린다. 평균두께는 이쪽이 낸 값을 **넘겨 준다**(저쪽은 두께를 모름). ⚠ 「기초유 / 기초버림」은 정본 탭 제목이고 **기초분 0.45 냐 0.07 이냐**를 가른다. 안 고른 프로젝트는 **비탈분만** 세우고 사유를 남긴다 — 한쪽으로 찍으면 임의값이 금액으로 굳는다(기초분이 통째로 0.45㎥/m 다). """ foundation = str(options.get("foundation") or "").strip() if foundation in ("기초유", "기초버림"): has_foundation = foundation == "기초유" area = wall_trench_area_m2(height_m, thickness, has_foundation=has_foundation) base_depth = WALL_FOUNDATION_DEPTH_M if has_foundation else WALL_BLINDING_DEPTH_M base_basis = f"기초분({foundation}) + " else: width = wall_trench_width_m(thickness) area = None if width is None else height_m * width base_depth = 0.0 base_basis = "" notes.append( "⚠ 기초 유/무(기초유·기초버림)를 안 정해 **기초 터파기 몫이 안 섰음** — 비탈분만 세움" ) if area is None: notes.append("⚠ 높이·평균두께가 없어 터파기가 서지 않음") return [] excavation = area * length_m backfill = (base_depth + height_m) * WALL_TRENCH_CLEARANCE_M * length_m basis = ( f"{base_basis}H × (평균두께 {thickness:.2f} + {WALL_TRENCH_CLEARANCE_M}) × 연장" f" · {BASIS_WALL}" ) if extra_basis: basis = f"{basis} · {extra_basis}" return [ Component("터파기", "㎥", excavation, DESTINATION["터파기"], basis), Component( "되메우기", "㎥", backfill, DESTINATION["되메우기"], f"(기초깊이 {base_depth:g} + H) × {WALL_TRENCH_CLEARANCE_M} × 연장", ), Component( "잔토처리", "㎥", excavation - backfill, DESTINATION["잔토처리"], "터파기 − 되메우기", ), ] def stone_masonry( height_m: float, length_m: float, options: dict[str, Any], wet: bool, face: str | None = None, face_reason: str = "", ) -> tuple[list[Component], list[str]]: """돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식. 실측 대조(m당, H=1.5, 뒷길이 45㎝, 기울기 1:0.3): 정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245 터파기 1.55 · 되메우기 0.30 · 잔토 1.25 """ notes: list[str] = [] if height_m <= 0 or length_m <= 0: return [], ["높이·연장이 없어 전개하지 않음"] back_cm = _back_length(options) if back_cm not in STONE_BACK_LENGTH_TABLE: # ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다. return [], [ f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다" ] table = dict(STONE_BACK_LENGTH_TABLE[back_cm]) # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). # 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고 # 멈추면 이미 저장된 프로젝트가 통째로 빈다. picked, kind_note = stone_coefficients(options, back_cm) if kind_note: notes.append(kind_note) # ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이 # 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음). if picked.get("kind"): for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): table[key] = picked.get(key) else: for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): if picked.get(key) is not None: table[key] = picked[key] # ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** — # ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로 # 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다. kind_label = str(picked.get("kind") or "") # ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도 # 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이 # 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.) # **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** / # 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」 # (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`). # 전면 기울기 — 품셈 표준경사표로 자동 판정하고, 저장 제원에 값이 있으면 그것이 이긴다. slope_ratio, slope_basis = face_slope_ratio( options, wet=wet, height_m=height_m, face=face, face_reason=face_reason ) constants = STONE_MASONRY face_area = height_m * length_m # 정면적 # 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.** # 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04). masonry_area = face_area * math.hypot(1.0, slope_ratio) # 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.** # ⚠ 표준도 제원에 상부·하부 두께 칸이 있으면 **그 값이 식을 이긴다**(확정 ② 후반). back_m = back_cm / 100.0 # 석적(아래)이 뒷길이를 m 로 쓴다 top_thickness, bottom_thickness, thickness_basis = wall_thickness( options, back_cm=back_cm, height_m=height_m ) thickness = (top_thickness + bottom_thickness) / 2.0 volume = face_area * thickness # 입적 components = [ Component( "돌쌓기", "㎡", masonry_area, DESTINATION["돌쌓기"], f"정면적 × √(1+{slope_ratio:g}²) — 비탈면적 · {slope_basis}", ), # 입적 — 정본 계산표 좌측 열의 이름 그대로다(확정 ⑦). 값을 새로 내는 것이 아니라 # 이미 쓰고 있던 밑수를 화면에 드러내는 줄이다. Component( "입적", "㎥", volume, DESTINATION["입적"], f"정면적 × 평균두께 {thickness:.2f} · {thickness_basis}", ), ] # ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25). # 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과 # 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다. if table["wedge_stone_m3_per_m2"] is None: notes.append( f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝" + (f" · {kind_label}" if kind_label else "") + " 칸이 「-」입니다" ) else: components.append( Component( "고임돌", "㎥", masonry_area * _num(table["wedge_stone_m3_per_m2"]), DESTINATION["고임돌"], f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" + (f" · {kind_label}" if kind_label else ""), ) ) # 석적 — ⭐ 확정 5차 작은 것 4. 「체적」은 위 **입적** 줄이 이미 그것이고, 이쪽만 새 줄이다. stone_pile = face_area * back_m * STONE_PACKING_RATIO components.append( Component( "석적", "㎥", stone_pile, DESTINATION["석적"], f"정면적 × 뒷길이 {back_m:g}m × {STONE_PACKING_RATIO}(채움률) · {STONE_PILE_BASIS}", ) ) # 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌). # ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고, # 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다). stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2( back_cm, kind_label, table["stone_ton_per_m2"] ) weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else "" if not kind_label: notes.append( "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — " "야면석이면 계산식이 안 맞아 관측표로 갈립니다" ) if stone_ton is None: # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함") else: components.append( Component( stone_name, "ton", masonry_area * stone_ton, DESTINATION.get(stone_name, "material"), weight_basis, source=weight_source, ) ) # 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가 # 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다. backfill_top = STONE_BACKFILL_WIDTH_M["top"] backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"] rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m components.append( Component( "막자갈", "㎥", rubble, DESTINATION["막자갈"], f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m" f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값", ) ) notes.append( f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · " f"하 {backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다" "(소광리는 같은 식에 0.30/0.60 을 씁니다)" ) # ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가 # 없으면 아무도 못 본다(2026-09-09 그물 침). if height_m > STONE_BACKFILL_VERIFIED_HEIGHT_M: notes.append( f"직고 {height_m:g}m 는 뒷채움 폭 정본 확인 구간" f"(H {STONE_BACKFILL_VERIFIED_HEIGHT_M:g}m 이하) **밖**입니다 — 붙박이 폭이라 " "직고가 커져도 두께가 안 늘어 품셈 13-4-4 [주]⑨ 뒤채움 조약돌 두께 범위보다 " "얇아집니다(그 표는 직고와 함께 두꺼워짐). 값은 정본 식대로 세우되 " "**높은 벽의 뒷채움 폭은 실무자 확인이 필요합니다**" ) if wet: mpa, mpa_basis = fill_concrete_mpa(options) components.append( Component( "채움콘크리트", "㎥", masonry_area * _num(table["fill_concrete_m3_per_m2"]), DESTINATION["채움콘크리트"], # ⚠ 돌 종류로 계수가 갈리는데(깬돌 0.2 · 야면석 0.15) 근거에 종류가 안 # 적히던 자리다 — 값은 바뀌는데 **왜 바뀌었는지가 안 보였다**(고임돌은 # 적고 있었다). 2026-09-09 맞춤. f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" + (f" · {kind_label}" if kind_label else "") + f" · {mpa_basis}", spec=f"{mpa}", ) ) components.append( Component( "모르터", "㎥", masonry_area * constants["mortar_m3_per_m2"], DESTINATION["모르터"], f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)", ) ) # ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢). # 물구멍 — 벽면적 2㎡당 1개소, 개소당 0.5m. # ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고 # 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다. # ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에 # 「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 한다. 그 쪽을 쓰면 # 이 줄과 겹친다. **우리 선택은 이 줄을 세우고 제잡비는 아랫단(미설치)** 이다 # (`structure_unit_observed` 의 `double_count_rules`). # 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인). # ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면 # 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다. # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 — # **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식). # ⓘ 정본은 개소당 관 길이를 **평균두께**로 잡는다(H=2.0 에서 0.83m). 우리는 상수 0.5m 라 # 그만큼 짧게 선다 — 관은 벽을 가로지르므로 정본 쪽이 이치에 맞는다(미결). hole_area, hole_dia, hole_basis = weep_hole_spec(options) components.append( Component( "물구멍관", "m", masonry_area / hole_area * constants["weep_hole_length_m"], DESTINATION["물구멍관"], f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {constants['weep_hole_length_m']:g} m/개소" f" · {hole_basis}", spec=f"Ø{hole_dia}", ) ) # 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.** # ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고 # 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다. if is_collected_stone(options): wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 collected = max(stone_pile + wedge + rubble, 0.0) if collected > 0: components.append( Component( "채집석", "㎥", collected, DESTINATION["채집석"], f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f} ㎥" " · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌", ) ) # 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다. # 잡석다짐 폭 = **하단 길이**(확정 ⑪) — 벽에서는 하부 두께다. 버림도 같은 폭을 쓴다. base_width = bottom_thickness blinding = _blinding_component( base_width, length_m, options, f"하단 길이(하부 두께) {bottom_thickness:.2f}m — 확정 ⑪ 「기준 구조물에서 유도」", ) if blinding is not None: components.append(blinding) notes.append(RUBBLE_BASE_BLOCKED.format(width=base_width)) # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). components.extend( _trench_components(height_m, length_m, thickness, options, notes, thickness_basis) ) return components, notes # 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다). # ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다. # 값의 키(규격)를 저장 제원의 어느 칸에서 읽는지 여기 적는다. 표에 규격이 없으면 # 「원단위 미확보」로 드러난다 — 가까운 값을 갖다 쓰지 않는다. OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = { "retaining_wall": ("form", "height_m"), "ford_pavement": ("thickness_cm",), # 배수관의 유입부 집수정은 관 자체와 **다른 줄**이다 — 관은 관대로 서고 집수정이 따로 선다. "pipe_inlet_basin": ("inlet_basin_form", "inlet_basin_material", "pipe_diameter_mm"), # 관보호공 날개벽 — **개소당**이고 치수가 형식마다 붙박이다(원본 탭 다섯). 형식을 고르지 # 않으면 날개벽 줄 자체가 안 선다(안 놓은 것과 같다). "pipe_wing_wall": ("wing_wall_type", "pipe_diameter_mm"), } EXPANDERS = { # 넷째·다섯째 인자는 **성토/절토와 그 까닭** — 표준경사 표가 그것으로 갈린다. "masonry_wet": lambda h, l, o, f=None, r="": stone_masonry(h, l, o, True, f, r), "masonry_dry": lambda h, l, o, f=None, r="": stone_masonry(h, l, o, False, f, r), # 큰돌쌓기는 표준경사 표 대상이 아니라 성절토를 안 쓴다(교본 「1:0.3 이상」). "boulder_masonry": lambda h, l, o, f=None, r="": boulder_masonry(h, l, o), # 기슭막이는 **형태가 돌쌓기면 돌쌓기 식**이다 — 실무 정본 탭 제목이 「돌기슭막이(…찰쌓기…)」 # 이고 그 안의 계산이 같다. 갈래·사유는 `_UnitQuantity_Revetment` 가 든다(늦게 부른다 — # 그쪽이 이 모듈의 `stone_masonry` 를 쓰므로 위에서 부르면 맞물린다). "revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r), # 골막이는 **개소당**이고 정면적이 사다리꼴이라 상장·하장·높이를 본다 # (정본 「골막이(찰)(치수조서연결)」). 돌쌓기 식을 빌려 쓰지 않는다 — 까닭은 그 모듈에 적었다. "erosion_check": lambda h, l, o, f=None, r="": _erosion_check()(h, o), # 개거는 **m당** 원단위라 연장이 밑수다(정본 「개거(150-200)」·「L형수로-(201)」). "open_ditch": lambda h, l, o, f=None, r="": _open_ditch()(l, o), # 흙막이는 **「떼」만 섬**(정본 「떼흙막이」 개소당). 나머지 일곱 형식은 원단위 미확보. # ⚠ 확정 4차 — 그림·옵션은 기슭막이와 한 벌이되 **수량 데이터는 분리**. "soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o), # 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」). "bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). # 앞서 `stone_masonry(dry)` 로 전개하고 있었는데, 그러면 직경 60~80㎝ 짜리가 # **「뒷길이 45㎝」 계수로 돌아 조용히 틀린 값**이 나온다(고임돌 0.15·야면석 0.88 …). # ⚠ 값이 나오기는 하므로 어떤 시험도 안 잡던 자리다 — 「값이 있기는 하니 안 보이는」 그것. # 전개식·관측 원단위가 설 때까지 **미확보로 드러낸다.** } def _soil_guard(): """흙막이 사유 함수를 늦게 가져온다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard return soil_guard def _open_ditch(): """개거 전개 함수를 늦게 가져온다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import open_ditch return open_ditch def _bed_sill(): """바닥막이 전개 함수를 늦게 가져온다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill return bed_sill def _erosion_check(): """골막이·바닥막이 사유 함수 — 늦게 가져온다(서로 부르는 것을 푸는 자리).""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_dam return erosion_check_dam def _revetment(): """기슭막이 전개 함수를 늦게 가져온다 — 서로 부르는 것을 풀기 위한 자리.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import revetment return revetment #: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다. EXPANDER_WITHHELD: dict[str, str] = {} #: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다 #: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다. ATTACHMENTS: dict[str, tuple[tuple[str, str, str], ...]] = { # (붙는 종류, 그것이 있는지 보는 옵션 칸, 줄 이름 꼬리) "pipe": ( ("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"), ("pipe_wing_wall", "wing_wall_type", "관보호공 날개벽"), ), } #: 날개벽 「A-TYPE+집수정」은 집수정을 **품은** 형식이다 — 집수정 줄을 따로 세우면 한 개소를 #: 두 번 센다(원단위표 `double_count_rules` 와 짝). WING_WALL_WITH_BASIN = "A-TYPE+집수정" WING_WALL_DOUBLE_COUNT = ( "⚠ 날개벽을 「A-TYPE+집수정」으로 골랐는데 집수정 형식도 차 있음 — " "그 날개벽 값이 집수정을 이미 품고 있어 **두 번 셀 수 있음**. 한쪽을 비울 것" ) def wing_wall_double_count(options: dict[str, Any]) -> str | None: """겹쳐 세는 자리면 사유 한 줄. 값을 고치지 않고 **드러내기만** 한다.""" if str(options.get("wing_wall_type") or "") != WING_WALL_WITH_BASIN: return None return WING_WALL_DOUBLE_COUNT if options.get("inlet_basin_form") else None def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]: """구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지).""" rows: list[dict[str, Any]] = [] options = structure.get("options") or {} for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()): if not options.get(gate_key): continue # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다 rows.append( { **structure, "structure_id": f"{structure.get('structure_id')}-{type_id}", "type_id": type_id, "attachment_of": structure.get("structure_id"), "attachment_parent_type": structure.get("type_id"), "attachment_label": label, } ) return rows def _observed_components( type_id: str, structure: dict[str, Any], observed: ObservedUnitTable | None, ) -> tuple[list[dict[str, Any]], list[str]]: """관측 원단위표에서 꺼낸다. 규격 키가 정해져 있지 않은 종류는 건드리지 않는다.""" keys = OBSERVED_SPEC_KEYS.get(type_id) if keys is None: return [], [] options = structure.get("options") or {} spec = {key: options[key] for key in keys if options.get(key) is not None} if not spec: from B08_Quantity.B08_Quantity_Wording import option_missing return [], [option_missing(keys[0], type_id)] components, notes = expand_observed(type_id, spec, structure, observed) warning = wing_wall_double_count(options) if warning: notes.append(warning) return components, notes def _observed_billing( type_id: str, structure: dict[str, Any], observed: ObservedUnitTable | None, ) -> tuple[str, float] | None: """관측표가 정한 **내역 단위와 개수**. 규격 키가 없는 종류는 건드리지 않는다.""" keys = OBSERVED_SPEC_KEYS.get(type_id) if keys is None: return None options = structure.get("options") or {} spec = {key: options[key] for key in keys if options.get(key) is not None} if not spec: return None return billing_of(type_id, spec, structure, observed) def expand( structure: dict[str, Any], names: dict[str, str] | None = None, observed: ObservedUnitTable | None = None, section_mode: str | None = None, ) -> StructureQuantity: """구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지).""" type_id = str(structure.get("type_id") or "") options = structure.get("options") or {} start = _num(structure.get("start_m")) end = _num(structure.get("end_m")) length = _num(options.get("length_m")) or abs(end - start) height = _num(options.get("height_m")) # ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** — # `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사). from B08_Quantity.B08_Quantity_Wording import type_label as _type_label label = _type_label(type_id, names) if structure.get("attachment_label"): # 「배수관 · 유입부 집수정」처럼 어디에 딸린 줄인지 이름에 남긴다. parent = (names or {}).get(str(structure.get("attachment_parent_type") or ""), "") label = f"{parent or label} · {structure['attachment_label']}".strip(" ·") result = StructureQuantity( structure_id=structure.get("structure_id"), type_id=type_id, name=label, length_m=length, height_m=height, start_m=start if structure.get("start_m") is not None else None, end_m=end if structure.get("end_m") is not None else None, options=dict(options), ) withheld = EXPANDER_WITHHELD.get(type_id) if withheld: result.notes.append(f"전개식 미확보 — {withheld}") return result expander = EXPANDERS.get(type_id) if expander is None: # 전개식이 없으면 **관측 원단위표**를 본다(치수가 저장돼 있지 않은 종류). components, notes = _observed_components(type_id, structure, observed) if components or notes: result.components = [Component(**item) for item in components] result.notes.extend(notes) billing = _observed_billing(type_id, structure, observed) if billing is not None: result.billing_unit, result.billing_quantity = billing return result from B08_Quantity.B08_Quantity_Wording import type_label result.notes.append( f"{type_label(type_id, names)}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다" ) return result # 성토/절토 — **판정 한 벌**을 부른다(우리가 따로 짜지 않는다). face, face_reason = structure_face_role(section_mode, options.get("side")) result.components, notes = expander(height, length, options, face, face_reason) result.notes.extend(notes) return result def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]: """⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어). 시멘트·모래·자갈은 **B09 일위대가**가 배합표로 분해할 값이다. 여기서 내면 두 배가 된다. 실무 원단위 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다. """ found: list[str] = [] for item in quantities: for component in item.components: if component.name.strip() in MIX_COMPONENTS: found.append(f"{item.name}({item.type_id}) 의 '{component.name}'") return found def section_modes_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, str]: """저장된 횡단 설계 목록 → `{측점: 단면유형}`. ⚠ **부르는 쪽마다 다시 짜지 말라고 여기 둔다.** 구조물 전개(B08)와 표준도(B07)가 같은 표를 써야 기울기 판정이 두 곳에서 갈리지 않는다. `design.section_mode` 가 없는 측점은 담지 않는다 — 빈 값을 담으면 「가를 근거 없음」과 「모드가 빈 문자열」이 뒤섞인다. """ modes: dict[float, str] = {} for item in designs or (): design = item.get("design") if isinstance(item, dict) else None mode = str((design or {}).get("section_mode") or "").strip() if not mode: continue chainage = _num(item.get("chainage_m")) modes[float(chainage)] = mode return modes #: 저장된 지반 갈래 ↔ 품셈 9-13 토질 3구분. **새 칸을 만들지 않는다** — 측점마다 이미 #: `design.ground_type` 이 저장돼 있고(재생성 사고 때 이 값이 비어 B08 이 통째로 0 이 됐던 #: 그 키다), 값 셋이 품셈 구분과 그대로 맞물린다(2026-09-08 조율 창 확인). GROUND_TYPE_LABEL = { "soil": "토사", "ripping_rock": "암절취", "blasting_rock": "발파암", } def ground_types_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, str]: """저장된 횡단 설계 목록 → `{측점: 지반갈래}`. 값이 없는 측점은 담지 않는다.""" grounds: dict[float, str] = {} for item in designs or (): design = item.get("design") if isinstance(item, dict) else None ground = str((design or {}).get("ground_type") or "").strip() if not ground: continue grounds[float(_num(item.get("chainage_m")))] = ground return grounds def ground_type_at( structure: dict[str, Any], ground_types: dict[float, str] | None ) -> tuple[str | None, str]: """(토질, 근거). 구조물이 **걸친 측점 전부**를 보고 갈래가 하나일 때만 값을 낸다. ⚠ **판정 규칙 — 섞이면 안 고른다.** 구조물은 구간(start~end)이고 지반은 측점 값이라 한 구조물이 토사 측점과 암 측점에 걸칠 수 있다. 그때 다수결로 한쪽을 고르면 **임의값이 금액으로 굳는다**(암 단가가 몇 배다). 섞였다는 사실과 갈래별 측점 수를 근거에 적고 값은 `None` 으로 둔다 — 성절토·용수에서 지킨 그대로다. ⚠ 걸친 측점이 하나도 없으면(구간이 측점 사이에 통째로 들어간 짧은 구조물) **가장 가까운 측점**을 쓴다 — 그 사실도 근거에 적는다. """ if not ground_types: return None, "측점 지반 갈래가 저장에 없어 못 가름" start, end = _num(structure.get("start_m")), _num(structure.get("end_m")) if end < start: start, end = end, start inside = { chainage: kind for chainage, kind in ground_types.items() if start <= float(chainage) <= end } if not inside: center = (start + end) / 2.0 nearest = min(ground_types, key=lambda chainage: abs(float(chainage) - center)) kind = ground_types[nearest] return ( kind, f"걸친 측점이 없어 가장 가까운 측점({nearest:g}m)의 {GROUND_TYPE_LABEL.get(kind, kind)}", ) counts: dict[str, int] = {} for kind in inside.values(): counts[kind] = counts.get(kind, 0) + 1 if len(counts) == 1: kind = next(iter(counts)) return kind, f"걸친 측점 {len(inside)}곳이 모두 {GROUND_TYPE_LABEL.get(kind, kind)}" breakdown = " · ".join( f"{GROUND_TYPE_LABEL.get(kind, kind)} {count}곳" for kind, count in sorted(counts.items()) ) return None, f"걸친 측점의 지반이 섞여 못 가름 — {breakdown}" def _section_mode_at( structure: dict[str, Any], section_modes: dict[float, str] | None ) -> str | None: """구조물이 선 자리의 단면유형. **가장 가까운 측점**의 값을 쓴다. ⚠ 구조물은 구간(start~end)이고 단면유형은 측점 값이라 딱 맞는 측점이 없을 수 있다. 가장 가까운 측점을 쓰되, 목록이 없으면 `None`(가를 근거 없음)으로 둔다 — **성토로 눅이지 않는다.** """ if not section_modes: return None center = _num(structure.get("chainage_m")) if not center: start, end = _num(structure.get("start_m")), _num(structure.get("end_m")) center = (start + end) / 2.0 if (start or end) else 0.0 nearest = min(section_modes, key=lambda chainage: abs(float(chainage) - center)) return section_modes.get(nearest) def _rubble_base_component( components: list[Component], thickness_m: float | None ) -> Component | None: """기초잡석 한 줄 — **버림 폭이 곧 잡석다짐 폭**이라 두께 비로 낸다(확정 3차 ②). ⚠ 폭을 다시 세지 않는다. 버림이 이미 그 폭으로 서 있으므로 두께 비만 곱하면 **관측 원단위로 오는 구조물(옹벽)에도 같은 식이 선다** — 두 벌로 짜지 않는 자리다. """ thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m) if thickness <= 0: return None blinding = next((item for item in components if item.name == "버림콘크리트"), None) if blinding is None or blinding.amount <= 0: return None ratio = thickness / BLINDING_THICKNESS_M return Component( RUBBLE_BASE_NAME, "㎥", blinding.amount * ratio, DESTINATION[RUBBLE_BASE_NAME], f"버림 {blinding.amount:.3f}㎥ × (잡석두께 {thickness:g} ÷ 버림두께" f" {BLINDING_THICKNESS_M:g}) — 폭이 같음(KCS 34 50 05) · 두께는 사용자 확정 3차 ②" " (품셈 12-25 는 ㎥당 품만 주고 두께를 정하지 않음)", ) def build_table( structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None, section_modes: dict[float, str] | None = None, ground_types: dict[float, str] | None = None, rubble_base_thickness_m: float | None = None, ) -> dict[str, Any]: """화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.""" observed = load_observed_table() # 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다. expanded_inputs: list[dict[str, Any]] = [] for item in structures: expanded_inputs.append(item) expanded_inputs.extend(attachments_of(item)) quantities = [] for item in expanded_inputs: quantity = expand(item, names, observed, _section_mode_at(item, section_modes)) quantity.ground_type, quantity.ground_type_basis = ground_type_at(item, ground_types) # 기초잡석 — 버림이 선 구조물에 함께 선다(전개식이든 관측 원단위든 같은 자리). rubble = _rubble_base_component(quantity.components, rubble_base_thickness_m) if rubble is not None: quantity.components.append(rubble) quantities.append(quantity) violations = verify_no_mix_components(quantities) totals: dict[str, dict[str, Any]] = {} for item in quantities: for component in item.components: key = f"{component.name}|{component.unit}" entry = totals.setdefault( key, { "name": component.name, "unit": component.unit, "amount": 0.0, "destination": component.destination, }, ) entry["amount"] += component.amount payload_structures = [ { "structure_id": item.structure_id, "type_id": item.type_id, "name": item.name, "length_m": item.length_m, "height_m": item.height_m, "start_m": item.start_m, "end_m": item.end_m, # 내역 줄이 설 단위·수량 — 관측 원단위가 「개소당」인 종류는 연장으로 못 센다. "billing_unit": item.billing_unit, "billing_quantity": item.billing_quantity, # 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다. "options": item.options, # 구조물이 놓인 자리의 지반 갈래 — **품셈 9-13 토질 3구분**이 이 값으로 갈린다. # ⚠ 여기서 새로 만드는 값이 아니라 측점 설계값(`design.ground_type`)을 옮긴 것이다. "ground_type": item.ground_type, "ground_type_basis": item.ground_type_basis, "notes": item.notes, "components": [ { "name": component.name, "unit": component.unit, "amount": component.amount, "destination": component.destination, "basis": component.basis, "basis_kind": component.basis_kind, "source": component.source, "spec": component.spec, } for component in item.components ], } for item in quantities ] # 거푸집 줄에 **몇 회짜리인지**를 달아 준다. 횟수별 재료 환산은 하지 않는다(B09 몫). formwork_notes, formwork_missing = annotate_formwork(payload_structures) return { "structures": payload_structures, "formwork_notes": formwork_notes, "formwork_reuse_missing": formwork_missing, # 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다. "shoring": shoring_status(), # ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다. # 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지. "pending_choices": (observed.pending_choices or {}).get("items") or [], "totals": sorted(totals.values(), key=lambda entry: entry["name"]), # 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠). "surcharge_applied": False, "mix_components_found": violations, "structure_count": len(quantities), # ⚠ 사토에서 뺄 밑수 — **양수**로 낸다. 빼는 것은 유토곡선(랩탑 메인) 몫이다. COLLECTED_STONE_KEY: round( sum( component.amount for item in quantities for component in item.components if component.name == "채집석" ), 3, ), "amount_spread": spread_by_unit(totals.values(), value_key="amount"), }