feat(b08): 표토제거 대상 고르개 — 노면 + 절토 사면(기본 · 법 문언) / 노면만

- 산출 조건 칸 topsoil_target · 기본 None = 별표2 Ⅰ.2.차.(6) 「노면·절토대상지」
- 노면만: 실무 영월 한 건(1m당 3.34㎡) 관측 — 사유에 법 문언이 아님을 함께 적음
- 칸 밑에 근거 두 줄 · 운반 부피도 고른 대상 따라감

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 00:14:46 +09:00
co-authored by Claude Opus 5
parent 82755260e2
commit 9f72ad19b8
7 changed files with 98 additions and 4 deletions
@@ -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)
@@ -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",
@@ -98,6 +98,8 @@ export interface QuantitySettings {
ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null;
/** 표토제거 대상 — 없음이면 기본(노면 + 절토) · `"road_only"` 는 노면만. */
topsoil_target?: string | null;
/** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */
frame_material?: Record<string, number | null>;
/** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */
+21
View File
@@ -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<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 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<void> {
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<string, number | null>) },
wood_chipping_enabled: Boolean(stored.wood_chipping_enabled),
@@ -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]]:
"""이 프로젝트가 쓸 토량환산계수 — **기본값 위에 고른 값만 얹는다.**
+16
View File
@@ -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"
+10
View File
@@ -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) 로 정한 값들 — **화면에 칸으로 세우고 근거를 보인다.**
// 사용자 지시: 「값을 코드에 박고 끝내지 말 것 · 대신 페이지에 남길 것」.