diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index df803dcd..743111f4 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -111,13 +111,14 @@ def preparation_rows( topsoil_haul_distance_m: float | None = None, stand_volume_class: str | None = None, road_surface_area_m2: float | None = None, + topsoil_road_only: bool = False, ) -> list[dict[str, Any]]: """준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다.""" slope = slope_totals or {} tree_area = float(slope.get("tree_removal_fill", 0.0)) + float( slope.get("tree_removal_cut", 0.0) ) - topsoil = _topsoil_row(slope, road_surface_area_m2) + topsoil = _topsoil_row(slope, road_surface_area_m2, topsoil_road_only) return [ { "group": "준비공", @@ -150,16 +151,19 @@ def preparation_rows( ] -def _topsoil_row(slope: dict[str, float], road_area_m2: float | None) -> dict[str, Any]: - """표토제거 — **「실무대로」 셋이 한 묶음**(2026-09-13 판정 · PLAN 10장). +def _topsoil_row( + slope: dict[str, float], road_area_m2: float | None, road_only: bool = False +) -> dict[str, Any]: + """표토제거 — **밑수 ㎡ · 대상 · 20m 압토 셋이 한 묶음**(2026-09-13 판정 · PLAN 10장). ① 밑수 **㎡** — 품셈 9-15-2 가 T=0.2 m 를 **품 조건**으로 둔 면적 축(실무 영월 「표토제거 답외구간 M2」). 두께는 여기 안 곱한다 — 운반 부피에만 쓴다. ② 대상 **노면 + 절토대상지** — 별표2 Ⅰ.2.차.(6) 문언. 성토대상지는 (7) 「제거·정리」라 안 셈. + `road_only` 면 노면만(영월 한 건 관측 · 설계자 선택 · 2026-09-14 판정). ③ **20 m 압토는 품에 듦** — 운반 줄은 그 넘는 몫만(`_topsoil_haul_row`). ⚠ 하나만 고치면 합계가 그럴듯해 보이면서 틀린다 — 셋을 함께 바꿨다. """ - cut = float(slope.get("face_dressing_cut", 0.0)) + cut = 0.0 if road_only else float(slope.get("face_dressing_cut", 0.0)) base = {"group": "준비공", "item": "표토제거", "unit": "㎡", "work_item_code": TOPSOIL_CODE} if road_area_m2 is None: return { @@ -185,6 +189,14 @@ def _topsoil_row(slope: dict[str, float], road_area_m2: float | None) -> dict[st "0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다" ), } + if road_only: + return { + **base, + "amount": area, + "status": STATUS_READY, + "reason": f"노면 {area:,.1f}㎡ (노면만 — 산출 조건에서 고름)" + f" — {TOPSOIL_ROAD_ONLY_BASIS}", + } return { **base, "amount": area, @@ -220,6 +232,12 @@ TOPSOIL_TARGET_BASIS = ( " · 실무 영월 「표토제거 답외구간 M2」)" " · 20m 압토는 제거 품에 듦(2026-09-13 판정 「실무대로」)" ) +#: 노면만 — 설계자가 고른 경우의 근거. ⚠ 법 문언이 아니라는 사실을 사유에 함께 적는다. +TOPSOIL_ROAD_ONLY_BASIS = ( + "⚠ 법 문언(별표2 Ⅰ.2.차.(6))은 「노면·절토대상지」라 기본은 노면 + 절토" + " · 노면만은 실무 영월 한 건(1m당 3.34㎡) 관측이라 설계자 선택" + " · 밑수 ㎡ · 20m 압토는 제거 품에 듦" +) #: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09). #: 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6): @@ -648,6 +666,7 @@ def build_table( chipping_enabled: Any = False, chipping_volume_m3: Any = None, road_surface_area_m2: float | None = None, + topsoil_road_only: bool = False, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = ( @@ -658,6 +677,7 @@ def build_table( topsoil_haul_distance_m, stand_volume_class, road_surface_area_m2, + topsoil_road_only, ) + erosion_rows(structures, names) + chipping_rows(chipping_enabled, chipping_volume_m3) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 49171e5e..d6cc73e7 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -51,6 +51,7 @@ from common_util.common_util_project_settings import ( quantity_settings, rock_classes, save_section, + topsoil_target, ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection @@ -169,6 +170,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: settings.get("wood_chipping_volume_m3"), # 표토제거 대상 「노면」 면적 — 사면과 같은 측점 설계에서(2026-09-13 판정 「실무대로」). road_surface_area(slopes), + # 대상 — 기본 노면 + 절토(별표2 문언) · 노면만은 설계자가 고름(2026-09-14 판정). + topsoil_target(settings) == "road_only", ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. @@ -440,6 +443,8 @@ class QuantitySettingsBody(BaseModel): structure_trench_water: str | None = None # 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. topsoil_haul_distance_m: float | None = None + # 표토제거 대상 — "road_only" 는 노면만. `""` 는 기본(노면 + 절토, 별표2 문언)으로 되돌림. + topsoil_target: str | None = None # 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다. stand_volume_class: str | None = None # 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. @@ -506,6 +511,10 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - method = values["concrete_placing_method"] # 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리). values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None + if "topsoil_target" in values: + # 기본(노면 + 절토)은 저장하지 않는다 — 「안 정함」과 같게 둬 법 문언이 선다. + target = values["topsoil_target"] + values["topsoil_target"] = target if target == "road_only" else None if "conversion_factors_override" in values: # 아는 갈래·양수만 남긴다. 사유는 값이 있을 때만 따라간다(계산에는 안 쓴다). cleaned: dict[str, Any] = {} @@ -540,6 +549,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - "rock_methods", "material_supply", "concrete_placing_method", + "topsoil_target", "ancillary_counts", # 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다. "conversion_factors_override", diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 81de4af1..a2eb1d0c 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -98,6 +98,8 @@ export interface QuantitySettings { ancillary_counts?: Record; /** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */ stand_volume_class?: string | null; + /** 표토제거 대상 — 없음이면 기본(노면 + 절토) · `"road_only"` 는 노면만. */ + topsoil_target?: string | null; /** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */ frame_material?: Record; /** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */ diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 3ece5f3e..c5b2741c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -102,6 +102,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr spoil_site_distance_m: draft.spoil_site_distance_m, structure_trench_water: draft.structure_trench_water, topsoil_haul_distance_m: draft.topsoil_haul_distance_m, + // `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다. + topsoil_target: draft.topsoil_target, stand_volume_class: draft.stand_volume_class, frame_material: draft.frame_material, wood_chipping_enabled: draft.wood_chipping_enabled, @@ -284,6 +286,8 @@ interface DraftSettings { structure_trench_water: string; // 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. `null` 은 「안 정함」. topsoil_haul_distance_m: number | null; + // 표토제거 대상 — `""` 기본(노면 + 절토, 별표2 문언) · "road_only" 노면만(설계자 선택). + topsoil_target: string; // 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). ancillary_counts: Record; // 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①). @@ -492,6 +496,22 @@ function buildQuantitySidePanel( // ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.** // 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리. panel.append(field(L("B08_Quantity_Side_Topsoil"), "")); + // 대상 — 기본은 법 문언(노면 + 절토). 영월 한 건이 노면만이라 3배 차이 → 설계자가 고름(2026-09-14). + panel.append( + selectField( + L("B08_Quantity_Side_TopsoilTarget_Label"), + draft.topsoil_target, + [ + { value: "", label: L("B08_Quantity_TopsoilTarget_RoadAndCut") }, + { value: "road_only", label: L("B08_Quantity_TopsoilTarget_RoadOnly") }, + ], + (value) => { + draft.topsoil_target = value; + draft.dirty = true; + }, + ), + ); + panel.append(hintRow(L("B08_Quantity_Side_TopsoilTarget_Hint"))); panel.append( optionalNumberField( L("B08_Quantity_Side_Topsoil_Label"), @@ -954,6 +974,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null, structure_trench_water: (stored.structure_trench_water as string) ?? "", topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null, + topsoil_target: (stored.topsoil_target as string) ?? "", stand_volume_class: (stored.stand_volume_class as string) ?? "", frame_material: { ...((stored.frame_material ?? {}) as Record) }, wood_chipping_enabled: Boolean(stored.wood_chipping_enabled), diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 811d2a50..989dc34a 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -140,6 +140,10 @@ def default_settings() -> dict[str, Any]: # 높은 장소로 **운반하고 쌓아두어야** 한다」. 제거만 세면 법이 요구하는 운반이 빠진다. # 거리는 현장값이라 품셈이 정하지 않는다 — 비면 운반 줄이 막힌 채로 선다. "topsoil_haul_distance_m": None, + # 표토제거 대상 — `None` 은 기본 「노면 + 절토」(별표2 문언) · `"road_only"` 는 노면만. + # ⚠ 노면만은 실무 영월 한 건(1m당 3.34㎡) 관측이라 일반화하지 않고 설계자가 고름 + # (2026-09-14 브레인 판정 — 3배 차이라 칸으로 둠). + "topsoil_target": None, # 임목축적 등급 — 품셈 9-21 제근이 **소·중·밀** 셋으로 갈리는 축. # [주]① 「소림 30~60㎥/㏊ · 중림 60~90 · 밀림 90 이상」 — 본수가 아니라 **축적**이다. # ⚠ 산림조사부·영림계획에서 옮겨 적는 값이라 프로그램이 만들 수 없다. 비면 막는다. @@ -267,6 +271,17 @@ def concrete_placing_method(settings: dict[str, Any]) -> tuple[str, bool]: return DEFAULT_CONCRETE_PLACING_METHOD, True +#: 표토제거 대상 — 기본은 법 문언(별표2 Ⅰ.2.차.(6) 「노면·절토대상지」). +TOPSOIL_TARGETS = ("road_and_cut", "road_only") +DEFAULT_TOPSOIL_TARGET = "road_and_cut" + + +def topsoil_target(settings: dict[str, Any]) -> str: + """표토제거 대상. 모르는 값·안 정함은 기본(노면 + 절토).""" + stored = settings.get("topsoil_target") + return str(stored) if stored in TOPSOIL_TARGETS else DEFAULT_TOPSOIL_TARGET + + def earthwork_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]: """이 프로젝트가 쓸 토량환산계수 — **기본값 위에 고른 값만 얹는다.** diff --git a/resources/tester/test_b08_topsoil_haul.py b/resources/tester/test_b08_topsoil_haul.py index dbb0d04c..369e90a7 100644 --- a/resources/tester/test_b08_topsoil_haul.py +++ b/resources/tester/test_b08_topsoil_haul.py @@ -145,3 +145,19 @@ def test_노면_면적은_노체_폭의_평균단면적() -> None: is None ) assert road_surface_area([]) is None + + +def test_노면만을_고르면_절토_사면을_안_더한다() -> None: + """⭐ 2026-09-14 판정 — 기본은 법 문언(노면 + 절토) · 노면만은 영월 한 건이라 설계자가 고름.""" + rows = {r["item"]: r for r in preparation_rows(사면, [], 0.2, 300.0, None, 노면, True)} + assert rows["표토제거"]["amount"] == 600.0 and "노면만" in rows["표토제거"]["reason"] + assert "법 문언" in rows["표토제거"]["reason"] + assert rows["표토 운반·적치"]["amount"] == 600.0 * 0.2 # 운반도 고른 대상 따라감 + + +def test_대상_설정은_모르는_값이면_기본() -> None: + from common_util.common_util_project_settings import topsoil_target + + assert topsoil_target({}) == "road_and_cut" + assert topsoil_target({"topsoil_target": "road_only"}) == "road_only" + assert topsoil_target({"topsoil_target": "cut_only"}) == "road_and_cut" diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index aebb8161..3f53bbd3 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -652,6 +652,16 @@ export const ui_locales_b2 = { B08_Quantity_Dev_Failed: ["단계 잠금을 바꾸지 못했습니다.", "Failed to update stage lock."], B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"], B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"], + B08_Quantity_Side_TopsoilTarget_Label: ["표토제거 대상", "Topsoil removal target"], + B08_Quantity_TopsoilTarget_RoadAndCut: [ + "노면 + 절토 사면 (기본 · 법 문언)", + "Road surface + cut slope (default · law)", + ], + B08_Quantity_TopsoilTarget_RoadOnly: ["노면만", "Road surface only"], + B08_Quantity_Side_TopsoilTarget_Hint: [ + "기본 근거: 산림자원법 시행규칙 별표2 Ⅰ.2.차.(6) 「노면·절토대상지에 있는 … 표토는 전량 제거한 후 … 운반하고 쌓아두어야 한다」 — 성토 사면은 「제거·정리」라 대상 밖. 노면만: 실무 영월 한 건(1m당 3.34㎡)이 노면만 셈 — 한 건이라 기본으로 삼지 않고 고를 수 있게 둠(면적 약 3배 차이)", + "Default basis: the forest-road rule names road surface and cut areas; fill slopes are only cleared. Road-only follows one field case (Yeongwol, 3.34 ㎡ per m) — selectable, not the default (about 3× area difference)", + ], B08_Quantity_Unset_Placeholder: ["안 정함", "Not set"], // ── 확정 3차(2026-09-09) 로 정한 값들 — **화면에 칸으로 세우고 근거를 보인다.** // 사용자 지시: 「값을 코드에 박고 끝내지 말 것 · 대신 페이지에 남길 것」.