diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 83b42fde..b8a75805 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -99,6 +99,7 @@ def preparation_rows( slope_rows: Iterable[dict[str, Any]] = (), topsoil_thickness_m: float | None = None, topsoil_haul_distance_m: float | None = None, + stand_volume_class: str | None = None, ) -> list[dict[str, Any]]: """준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다.""" slope = slope_totals or {} @@ -125,15 +126,7 @@ def preparation_rows( topsoil, # ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2). _topsoil_haul_row(topsoil, topsoil_haul_distance_m), - { - "group": "준비공", - "item": "제근·뿌리다듬기", - "unit": "개", - "amount": None, - "status": STATUS_PENDING, - "reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)", - "work_item_code": "FP-09-21", - }, + _root_removal_row(slope, stand_volume_class), _batter_frame_row(list(slope_rows)), _level_frame_row(list(slope_rows)), ] @@ -185,6 +178,48 @@ TOPSOIL_HAUL_LAW = ( ) +#: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(2026-09-09 원문 확인). +#: 품셈 9-21 [주]① 「소림 : 임목축적이 30㎥/㏊ 이상 60 미만 · 중림 : 60 이상 90 미만 · +#: 밀림 : 90 이상」. ⇒ 물어야 할 값은 **본/ha 밀도가 아니라 축적 등급**이다. +#: ⚠⚠ **그런데 원문 표에 밑수 단위 열이 없다** — 9-20(뿌리다듬기·적재)은 「10주당」이라 +#: 적혀 있는데 9-21 표에는 그 표기가 없다. 등급을 넣어도 **무엇당 값인지 모르면 못 센다.** +#: 지어내지 않고 그 사실을 사유로 낸다(원문 확인이 필요한 자리). +STAND_VOLUME_CLASSES = ("소림", "중림", "밀림") +ROOT_REMOVAL_BASIS_UNKNOWN = ( + "⚠ 품셈 9-21 표에 **밑수 단위가 없음** — 9-20 은 「10주당」이라 적혀 있으나 9-21 에는" + " 그 표기가 없어 「무엇당 값」인지 원문으로 확인해야 함. 등급을 넣어도 그 전에는 못 셈" +) +ROOT_REMOVAL_CLASS_MISSING = ( + "임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·" + "중림(60~90)·밀림(90 이상)으로 가름. ⚠ 본수가 아니라 **축적**이고, 산림조사부·영림계획에서" + " 옮겨 적는 값이라 프로그램이 만들 수 없음" +) + + +def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -> dict[str, Any]: + """제근·뿌리다듬기 — 등급과 밑수 단위가 다 서야 값이 난다. + + ⚠ 대상 면적은 지장목제거와 같은 자리(벌개제근 연동)라 이미 서 있다 — 참고로 보인다. + """ + area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0)) + picked = str(stand_volume_class or "").strip() + reasons = [ROOT_REMOVAL_BASIS_UNKNOWN] + if picked in STAND_VOLUME_CLASSES: + reasons.append(f"등급은 「{picked}」로 정해져 있음") + else: + reasons.append(ROOT_REMOVAL_CLASS_MISSING) + return { + "group": "준비공", + "item": "제근·뿌리다듬기", + "unit": "개", + "amount": None, + "status": STATUS_PENDING, + "reason": " · ".join(reasons) + f" (대상 면적 {area:,.1f}㎡ — 벌개제근 연동)", + "reference_amount": area, + "work_item_code": "FP-09-21", + } + + def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]: """표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력. @@ -417,10 +452,17 @@ def build_table( names: dict[str, str] | None = None, ancillary_counts: dict[str, Any] | None = None, topsoil_haul_distance_m: float | None = None, + stand_volume_class: str | None = None, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = ( - preparation_rows(slope_totals, slope_rows, topsoil_thickness_m, topsoil_haul_distance_m) + preparation_rows( + slope_totals, + slope_rows, + topsoil_thickness_m, + topsoil_haul_distance_m, + stand_volume_class, + ) + erosion_rows(structures, names) + ancillary_rows(ancillary_counts) ) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 9879da54..2ebb4d30 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -140,6 +140,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: settings.get("ancillary_counts") or {}, # 표토 운반거리 — 별표2 가 요구하는 운반·적치의 밑수(거리는 현장값). settings.get("topsoil_haul_distance_m"), + # 임목축적 등급 — 품셈 9-21 제근이 소·중·밀로 갈리는 축(본수가 아니다). + settings.get("stand_volume_class"), ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. @@ -320,6 +322,8 @@ class QuantitySettingsBody(BaseModel): structure_trench_water: str | None = None # 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. topsoil_haul_distance_m: float | None = None + # 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다. + stand_volume_class: str | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 09f24d24..681fb0b4 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -79,6 +79,8 @@ export interface QuantitySettings { topsoil_haul_distance_m?: number | null; /** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */ ancillary_counts?: Record; + /** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */ + stand_volume_class?: string | null; } export interface EarthworkTable { diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 39193713..72fb1ada 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -92,6 +92,7 @@ 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, + stand_volume_class: draft.stand_volume_class, // 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다. ancillary_counts: draft.ancillary_counts, }), @@ -265,6 +266,8 @@ interface DraftSettings { topsoil_haul_distance_m: number | null; // 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). ancillary_counts: Record; + // 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①). + stand_volume_class: string; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; dirty: boolean; @@ -514,6 +517,23 @@ function buildQuantitySidePanel( ), ); panel.append(hintRow(L("B08_Quantity_Side_TopsoilHaul_Hint"))); + panel.append( + selectField( + L("B08_Quantity_Side_StandVolume_Label"), + draft.stand_volume_class, + [ + { value: "", label: L("B08_Quantity_StandVolume_Unset") }, + { value: "소림", label: L("B08_Quantity_StandVolume_Low") }, + { value: "중림", label: L("B08_Quantity_StandVolume_Mid") }, + { value: "밀림", label: L("B08_Quantity_StandVolume_High") }, + ], + (value) => { + draft.stand_volume_class = value; + draft.dirty = true; + }, + ), + ); + panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint"))); // ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ── panel.append(field(L("B08_Quantity_Side_Ancillary"), "")); @@ -788,6 +808,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, + stand_volume_class: (stored.stand_volume_class as string) ?? "", ancillary_counts: { ...((stored.ancillary_counts ?? {}) as Record), }, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 2a0f6499..3503cb28 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -131,6 +131,10 @@ def default_settings() -> dict[str, Any]: # 높은 장소로 **운반하고 쌓아두어야** 한다」. 제거만 세면 법이 요구하는 운반이 빠진다. # 거리는 현장값이라 품셈이 정하지 않는다 — 비면 운반 줄이 막힌 채로 선다. "topsoil_haul_distance_m": None, + # 임목축적 등급 — 품셈 9-21 제근이 **소·중·밀** 셋으로 갈리는 축. + # [주]① 「소림 30~60㎥/㏊ · 중림 60~90 · 밀림 90 이상」 — 본수가 아니라 **축적**이다. + # ⚠ 산림조사부·영림계획에서 옮겨 적는 값이라 프로그램이 만들 수 없다. 비면 막는다. + "stand_volume_class": None, "dataset_versions": {}, }, "estimation": { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index fa400c21..ea2bb4c8 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -674,6 +674,15 @@ export const ui_locales_b2 = { "⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다", "⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items", ], + B08_Quantity_Side_StandVolume_Label: ["임목축적 등급", "Stand volume class"], + B08_Quantity_StandVolume_Unset: ["안 정함(줄이 막힘)", "Not set (row blocked)"], + B08_Quantity_StandVolume_Low: ["소림(30~60㎥/㏊)", "Low (30–60 ㎥/ha)"], + B08_Quantity_StandVolume_Mid: ["중림(60~90㎥/㏊)", "Mid (60–90 ㎥/ha)"], + B08_Quantity_StandVolume_High: ["밀림(90㎥/㏊ 이상)", "High (90+ ㎥/ha)"], + B08_Quantity_Side_StandVolume_Hint: [ + "제근(품셈 9-21)이 이 등급으로 갈립니다 — ⚠ 본수가 아니라 축적이고, 산림조사부·영림계획에서 옮겨 적는 값입니다", + "Root removal (9-21) splits by this class — stand volume, not tree count; copied from the forest survey", + ], B08_Quantity_Side_Ancillary: ["부대시설 개소", "Ancillary Facilities"], B08_Quantity_Side_Ancillary_Hint: [ "개소를 넣어야 줄이 섭니다 — 원문이 배치 간격을 정하지 않아 연장÷500 같은 산식을 쓰지 않습니다(확정 13)",