From 196bd73a5bfeed0058419291111cd882d86611a4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 04:18:14 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(b08):=20=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EB=B0=9B=EB=8A=94=20=EB=AA=A8=EC=96=91?= =?UTF-8?q?=C2=B7=EA=B0=92=20=EA=B1=B0=EB=A5=B4=EA=B8=B0=EB=A5=BC=20`=5FRo?= =?UTF-8?q?uter=5FEarthwork=5FSettings`=20=EB=A1=9C=20=EA=B0=80=EB=A6=84?= =?UTF-8?q?=20=E2=80=94=20695=EC=A4=84=20=C2=B7=20=ED=98=BC=ED=95=A9?= =?UTF-8?q?=EC=84=9D=20=EC=B9=B8=20=EB=84=A3=EA=B8=B0=20=EC=A0=84=20?= =?UTF-8?q?=EC=88=9C=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저장 자리(save_quantity_settings)는 라우터에 남김 · 이름은 다시 내보냄 · 소스 글자 시험 하나가 옮긴 파일을 봄 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B08_Quantity/B08_Quantity_Router_Earthwork.py | 155 +---------------- .../B08_Quantity_Router_Earthwork_Settings.py | 164 ++++++++++++++++++ resources/tester/test_b08_seed_spray_leaf.py | 6 +- 3 files changed, 177 insertions(+), 148 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Router_Earthwork_Settings.py diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index c79c0609..2e65a6e1 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -21,7 +21,6 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse -from pydantic import BaseModel from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Structures_Schema import structure_type_map @@ -50,6 +49,11 @@ from B08_Quantity.B08_Quantity_Engine_HaulSummary import ( ) from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import HAUL_PLAN_KEYS +from B08_Quantity.B08_Quantity_Router_Earthwork_Settings import ( # noqa: F401 — 다시 내보냄 + NULLABLE_SETTING_KEYS, + QuantitySettingsBody, + clean_setting_values, +) from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import ( recompute_haul_plan as _recompute_haul_plan, ) @@ -58,8 +62,6 @@ from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slop from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes from B08_Quantity.B08_Quantity_Provenance import quantity_provenance from common_util.common_util_project_settings import ( - CONCRETE_PLACING_METHODS, - ROCK_METHODS, application_ratio, concrete_placing_method, earthwork_conversion_choices, @@ -74,9 +76,7 @@ from common_util.common_util_project_settings import ( from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection from config.config_system_design import ( - EARTHWORK_CONVERSION_FACTORS, EARTHWORK_CONVERSION_PUMSEM_C_RANGES, - EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, ) logger = logging.getLogger(__name__) @@ -471,89 +471,6 @@ async def _stored_haul_plan(project_id: UUID, route_id: int) -> dict[str, Any] | return plan if isinstance(plan, dict) and plan else None -class QuantitySettingsBody(BaseModel): - """[저장]이 보내는 산출 조건. 보내지 않은 칸은 저장분을 그대로 둔다.""" - - rock_class_set: str | None = None - rock_classes: list[str] | None = None - rock_ratios_pct: dict[str, float] | None = None - # 갈래별 시공법 — 값은 "ripping"·"blasting". 안 정한 갈래는 보내지 않는다. - rock_methods: dict[str, str] | None = None - application_ratios_pct: dict[str, float] | None = None - # 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`. - # 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정). - material_supply: dict[str, Any] | None = None - # 자재별 할증률 — `{이름 규격: %}`. 품셈 1-3-1 「표의 값 이내」라 0~표값만 씀(엔진이 거름). - material_surcharge: dict[str, Any] | None = None - # 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다. - concrete_placing_method: str | None = None - # 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②). - topsoil_thickness_m: float | None = None - # 부대시설 개소 — `{항목키: 개소}`(2026-09-09 확정 ⑬). - # ⚠ 산식(연장÷500)으로 만들지 않는다 — 임도규정이 「필요시 거리를 조정」이라 하고 - # 기점 포함·갈림길 중복을 원문이 정하지 않는다. **설계자가 넣는 값**이다. - ancillary_counts: dict[str, float] | None = None - # 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①). - bench_cut_depth_m: float | None = None - # 사토장까지 운반거리(m). 유토곡선이 낸 사토를 **실어 내는 줄**이 이 값으로 선다. - spoil_site_distance_m: float | None = None - # 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05). - rubble_base_thickness_m: float | None = None - # 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다. - 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 - # 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」. - face_dressing_cut_class: str | None = None - # 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」. - root_removal_excavator_m3: str | None = None - # 초류종자살포 비탈면 토질 — "일반"·"마사토"(품셈 5-24 잎). `""` 는 「안 정함」. - seed_spray_ground: str | None = None - face_dressing_fill_class: str | None = None - # 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ). - face_dressing_fill_area_m2: float | None = None - face_dressing_cut_area_m2: float | None = None - # 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. - frame_material: dict[str, Any] | None = None - # 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다. - wood_chipping_enabled: bool | None = None - wood_chipping_volume_m3: float | None = None - # 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. - subgrade_compaction_enabled: bool | None = None - # 임목폐기물 — 현장 조사값 넷(통째로 갈아 끼움) · 수동 처리단가 · 분리발주. - tree_waste: dict[str, float | None] | None = None - tree_waste_unit_price_krw_per_ton: float | None = None - waste_separate_order: bool | None = None - # 뿌리 산정법 — "root_ball"(뿌리분 체적 × 1,300) · `""` 는 기본(분배비 15/85)으로 되돌림. - tree_waste_root_method: str | None = None - # 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`. - # ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다. - # 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다. - conversion_factors_override: dict[str, Any] | None = None - # 도쟈 한계거리(m) — `None` 은 기본값(60 m). 종무대 20 m 보다 커야 한다(도쟈 몫이 사라짐). - dozer_haul_limit_m: float | None = None - - -#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. -#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다. -NULLABLE_SETTING_KEYS = ( - "topsoil_thickness_m", - "bench_cut_depth_m", - "spoil_site_distance_m", - "rubble_base_thickness_m", - "topsoil_haul_distance_m", - "wood_chipping_volume_m3", - "dozer_haul_limit_m", - "tree_waste_unit_price_krw_per_ton", - "face_dressing_fill_area_m2", - "face_dressing_cut_area_m2", -) - - @router.put("/{project_id}/quantity/settings") async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse: """산출 조건을 정본에 남긴다 — [저장]이 부르는 자리. @@ -577,66 +494,10 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - for key in NULLABLE_SETTING_KEYS: if key in body.model_fields_set: values[key] = getattr(body, key) - dozer_limit = values.get("dozer_haul_limit_m") - free_haul = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"] or 0.0 - if dozer_limit is not None and dozer_limit <= free_haul: + error = clean_setting_values(values) + if error: # 조용히 기본값으로 돌리지 않는다 — 넣은 값이 안 쓰이는 줄 모른다. - return JSONResponse( - status_code=400, - content={ - "status": "error", - "message": f"도쟈 한계거리는 종무대 {free_haul:g} m 보다 커야 합니다.", - }, - ) - for key, choices in ( - ("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES), - ("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES), - ("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES), - ("seed_spray_ground", SEED_SPRAY_GROUNDS), - ): - if key in values and values[key] not in choices: - values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침 - if "concrete_placing_method" in values: - method = values["concrete_placing_method"] - # 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리). - values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None - if "tree_waste" in values: - # 양수만 남긴다 — 빈 칸은 키가 없어야 「안 넣음」으로 읽힌다. - values["tree_waste"] = { - key: float(value) - for key, value in (values["tree_waste"] or {}).items() - if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 - } - if "tree_waste_root_method" in values: - method = values["tree_waste_root_method"] - values["tree_waste_root_method"] = method if method == "root_ball" 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] = {} - for kind, entry in (values["conversion_factors_override"] or {}).items(): - if kind not in EARTHWORK_CONVERSION_FACTORS or not isinstance(entry, dict): - continue - value = entry.get("compacted") - if not isinstance(value, (int, float)) or isinstance(value, bool) or float(value) <= 0: - continue - kept: dict[str, Any] = {"compacted": float(value)} - reason = entry.get("reason") - if isinstance(reason, str) and reason.strip(): - kept["reason"] = reason.strip() - cleaned[kind] = kept - values["conversion_factors_override"] = cleaned - if "rock_methods" in values: - # 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로 - # 여기서 버리면 그 갈래는 미지정으로 돌아간다. - values["rock_methods"] = { - name: method - for name, method in values["rock_methods"].items() - if method in ROCK_METHODS - } + return JSONResponse(status_code=400, content={"status": "error", "message": error}) before = {key: quantity_settings(root).get(key) for key in HAUL_PLAN_KEYS} try: # ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면 diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork_Settings.py b/B08_Quantity/B08_Quantity_Router_Earthwork_Settings.py new file mode 100644 index 00000000..644b7380 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Earthwork_Settings.py @@ -0,0 +1,164 @@ +"""B08 산출 조건 [저장] — 받는 모양과 값 거르기 (2026-09-15 `Router_Earthwork` 700줄에서 가름). + +⚠ 순수 분리 — 칸·거르기 규칙은 그대로 옮김. 저장 자리(`save_quantity_settings`)는 라우터에 남음. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( + FACE_DRESSING_CUT_CLASSES, + FACE_DRESSING_FILL_CLASSES, + ROOT_REMOVAL_EXCAVATOR_SIZES, + SEED_SPRAY_GROUNDS, +) +from common_util.common_util_project_settings import CONCRETE_PLACING_METHODS, ROCK_METHODS +from config.config_system_design import ( + EARTHWORK_CONVERSION_FACTORS, + EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, +) + + +class QuantitySettingsBody(BaseModel): + """[저장]이 보내는 산출 조건. 보내지 않은 칸은 저장분을 그대로 둔다.""" + + rock_class_set: str | None = None + rock_classes: list[str] | None = None + rock_ratios_pct: dict[str, float] | None = None + # 갈래별 시공법 — 값은 "ripping"·"blasting". 안 정한 갈래는 보내지 않는다. + rock_methods: dict[str, str] | None = None + application_ratios_pct: dict[str, float] | None = None + # 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`. + # 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정). + material_supply: dict[str, Any] | None = None + # 자재별 할증률 — `{이름 규격: %}`. 품셈 1-3-1 「표의 값 이내」라 0~표값만 씀(엔진이 거름). + material_surcharge: dict[str, Any] | None = None + # 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다. + concrete_placing_method: str | None = None + # 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②). + topsoil_thickness_m: float | None = None + # 부대시설 개소 — `{항목키: 개소}`(2026-09-09 확정 ⑬). + # ⚠ 산식(연장÷500)으로 만들지 않는다 — 임도규정이 「필요시 거리를 조정」이라 하고 + # 기점 포함·갈림길 중복을 원문이 정하지 않는다. **설계자가 넣는 값**이다. + ancillary_counts: dict[str, float] | None = None + # 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①). + bench_cut_depth_m: float | None = None + # 사토장까지 운반거리(m). 유토곡선이 낸 사토를 **실어 내는 줄**이 이 값으로 선다. + spoil_site_distance_m: float | None = None + # 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05). + rubble_base_thickness_m: float | None = None + # 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다. + 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 + # 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」. + face_dressing_cut_class: str | None = None + # 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」. + root_removal_excavator_m3: str | None = None + # 초류종자살포 비탈면 토질 — "일반"·"마사토"(품셈 5-24 잎). `""` 는 「안 정함」. + seed_spray_ground: str | None = None + face_dressing_fill_class: str | None = None + # 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ). + face_dressing_fill_area_m2: float | None = None + face_dressing_cut_area_m2: float | None = None + # 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. + frame_material: dict[str, Any] | None = None + # 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다. + wood_chipping_enabled: bool | None = None + wood_chipping_volume_m3: float | None = None + # 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. + subgrade_compaction_enabled: bool | None = None + # 임목폐기물 — 현장 조사값 넷(통째로 갈아 끼움) · 수동 처리단가 · 분리발주. + tree_waste: dict[str, float | None] | None = None + tree_waste_unit_price_krw_per_ton: float | None = None + waste_separate_order: bool | None = None + # 뿌리 산정법 — "root_ball"(뿌리분 체적 × 1,300) · `""` 는 기본(분배비 15/85)으로 되돌림. + tree_waste_root_method: str | None = None + # 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`. + # ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다. + # 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다. + conversion_factors_override: dict[str, Any] | None = None + # 도쟈 한계거리(m) — `None` 은 기본값(60 m). 종무대 20 m 보다 커야 한다(도쟈 몫이 사라짐). + dozer_haul_limit_m: float | None = None + + +#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. +#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다. +NULLABLE_SETTING_KEYS = ( + "topsoil_thickness_m", + "bench_cut_depth_m", + "spoil_site_distance_m", + "rubble_base_thickness_m", + "topsoil_haul_distance_m", + "wood_chipping_volume_m3", + "dozer_haul_limit_m", + "tree_waste_unit_price_krw_per_ton", + "face_dressing_fill_area_m2", + "face_dressing_cut_area_m2", +) + + +def clean_setting_values(values: dict[str, Any]) -> str | None: + """받은 값을 **그 자리에서** 거른다 — 거절할 값이면 사람이 읽는 까닭, 아니면 `None`.""" + dozer_limit = values.get("dozer_haul_limit_m") + free_haul = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"] or 0.0 + if dozer_limit is not None and dozer_limit <= free_haul: + # 조용히 기본값으로 돌리지 않는다 — 넣은 값이 안 쓰이는 줄 모른다. + return f"도쟈 한계거리는 종무대 {free_haul:g} m 보다 커야 합니다." + for key, choices in ( + ("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES), + ("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES), + ("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES), + ("seed_spray_ground", SEED_SPRAY_GROUNDS), + ): + if key in values and values[key] not in choices: + values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침 + if "concrete_placing_method" in values: + method = values["concrete_placing_method"] + # 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리). + values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None + if "tree_waste" in values: + # 양수만 남긴다 — 빈 칸은 키가 없어야 「안 넣음」으로 읽힌다. + values["tree_waste"] = { + key: float(value) + for key, value in (values["tree_waste"] or {}).items() + if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 + } + if "tree_waste_root_method" in values: + method = values["tree_waste_root_method"] + values["tree_waste_root_method"] = method if method == "root_ball" 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] = {} + for kind, entry in (values["conversion_factors_override"] or {}).items(): + if kind not in EARTHWORK_CONVERSION_FACTORS or not isinstance(entry, dict): + continue + value = entry.get("compacted") + if not isinstance(value, (int, float)) or isinstance(value, bool) or float(value) <= 0: + continue + kept: dict[str, Any] = {"compacted": float(value)} + reason = entry.get("reason") + if isinstance(reason, str) and reason.strip(): + kept["reason"] = reason.strip() + cleaned[kind] = kept + values["conversion_factors_override"] = cleaned + if "rock_methods" in values: + # 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로 + # 여기서 버리면 그 갈래는 미지정으로 돌아간다. + values["rock_methods"] = { + name: method + for name, method in values["rock_methods"].items() + if method in ROCK_METHODS + } + return None diff --git a/resources/tester/test_b08_seed_spray_leaf.py b/resources/tester/test_b08_seed_spray_leaf.py index f57b8072..c8921d0e 100644 --- a/resources/tester/test_b08_seed_spray_leaf.py +++ b/resources/tester/test_b08_seed_spray_leaf.py @@ -53,7 +53,11 @@ def test_선택지는_마스터_잎_이름과_같음() -> None: def test_산출_조건이_받고_선택지_밖은_안_정함으로() -> None: source = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8") - assert '("seed_spray_ground", SEED_SPRAY_GROUNDS)' in source + # 값 거르기는 2026-09-15 `_Router_Earthwork_Settings` 로 가름(700줄) — 규칙은 그대로. + settings = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork_Settings.py").read_text( + "utf-8" + ) + assert '("seed_spray_ground", SEED_SPRAY_GROUNDS)' in settings assert 'table["seed_spray_choices"]' in source material = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Material.py").read_text("utf-8") assert 'seed_spray_ground=settings.get("seed_spray_ground") or None' in material From 4186a851a4ea1368f7229974809c2293637ce987 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 04:55:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(b05=C2=B7b08):=20=ED=98=BC=ED=95=A9?= =?UTF-8?q?=EC=84=9D=20=EB=B6=80=EC=84=A4=20=E2=80=94=20=EB=B2=95=EB=A0=B9?= =?UTF-8?q?=20=EB=B3=84=ED=91=9C2=20=EC=A1=B0=EA=B1=B4=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99(=EC=A2=85=EB=8B=A8=208%=20=EC=B4=88=EA=B3=BC=20?= =?UTF-8?q?=ED=86=A0=EC=82=AC=20=C2=B7=20=EC=97=B0=EC=95=BD=C2=B7=EC=8A=B5?= =?UTF-8?q?=EC=9C=A4=20=EC=B9=B8)=20=C2=B7=20=E3=80=8C=EB=B9=84=ED=8F=AC?= =?UTF-8?q?=EC=9E=A5=20=EC=A0=84=20=EA=B5=AC=EA=B0=84=E3=80=8D=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B0=9C(=EA=B5=90=EB=B3=B8=203-2)=20=C2=B7=2011-4=20?= =?UTF-8?q?=E3=8E=A5=20+=20=EC=82=AC=EC=A0=84=ED=84=B0=ED=8C=8C=EA=B8=B0?= =?UTF-8?q?=20=ED=86=A0=EA=B3=B5=20=EC=B6=95=20=C2=B7=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=EC=B4=9D=EA=B4=84=20=ED=98=BC=ED=95=A9=EC=84=9D(12=EC=9E=A5=20?= =?UTF-8?q?D=20=ED=8F=AC=EC=9E=A5=20=EB=91=98=20=C2=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=E2=93=91=E2=91=A0~=E2=91=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자재 = 다짐 후 부피 ÷ C × L · 두께 제안 0.10(교본) · C 0.85 · L 1.25 제안(소광 관측 · 원문 표에 혼합석 줄 없음 · 역 C 와 방향 반대 사유) - 종단 경사는 계획선에서 B05 포장 제안과 같은 한 벌(local_grade_pct 로 뺌 — 종단 파일 측점 경사엔 비정규 측점이 없었음) - 해당 측점이 없으면 0 원 줄 안 세움 · 다짐 줄은 공종 없어 사유 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B05_Profile/B05_Profile_Engine_Sections.py | 34 ++-- .../B08_Quantity_Engine_EarthworkSummary.py | 28 +++ .../B08_Quantity_Engine_GravelSurfacing.py | 163 ++++++++++++++++++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 42 +++++ .../B08_Quantity_Router_Earthwork_Settings.py | 21 +++ B08_Quantity/B08_Quantity_Router_Material.py | 14 +- B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 2 + B08_Quantity/B08_Quantity_UI_Page.ts | 13 +- B08_Quantity/B08_Quantity_UI_Side_Gravel.ts | 159 +++++++++++++++++ .../work_item_mapping_2026-01-01.json | 15 ++ resources/tester/test_b08_gravel_surfacing.py | 127 ++++++++++++++ ui_template/ui_template_locale_b3.ts | 25 +++ 12 files changed, 624 insertions(+), 19 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_GravelSurfacing.py create mode 100644 B08_Quantity/B08_Quantity_UI_Side_Gravel.ts create mode 100644 resources/tester/test_b08_gravel_surfacing.py diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 2920c5b3..d1491a4b 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -295,6 +295,24 @@ def _append_design_profiles( return {"id": profile["id"], **profile["summary"]} +def local_grade_pct(points: list[tuple[float, float]], chainage: float) -> float: + """측점을 감싸는 인접 계획선 구간들의 경사 중 최댓값(절댓값 %)을 돌려준다. + + `points` = 계획선 `(누가거리, 표고)`. 포장 제안과 B08 혼합석 법령 조건(종단 8%)이 같은 한 벌을 씀. + """ + worst = 0.0 + for index in range(1, len(points)): + c0, z0 = points[index - 1] + c1, z1 = points[index] + if c1 < chainage - 1e-6 or c0 > chainage + 1e-6: + continue + span = c1 - c0 + if span <= 1e-9: + continue + worst = max(worst, abs((z1 - z0) / span) * 100.0) + return worst + + def _annotate_pavement_suggestions( longitudinal: dict[str, Any], grade_options: GradeDesignOptions | None ) -> None: @@ -326,25 +344,11 @@ def _annotate_pavement_suggestions( criteria["max_grade_pct"].get(terrain, criteria["max_grade_pct"]["normal"]) ) - def local_grade_pct(chainage: float) -> float: - """측점을 감싸는 인접 계획선 구간들의 경사 중 최댓값(절댓값 %)을 돌려준다.""" - worst = 0.0 - for index in range(1, len(points)): - c0, z0 = points[index - 1] - c1, z1 = points[index] - if c1 < chainage - 1e-6 or c0 > chainage + 1e-6: - continue - span = c1 - c0 - if span <= 1e-9: - continue - worst = max(worst, abs((z1 - z0) / span) * 100.0) - return worst - for station in stations: chainage = station.get("chainage_m") if not isinstance(chainage, (int, float)): continue - grade_pct = local_grade_pct(float(chainage)) + grade_pct = local_grade_pct(points, float(chainage)) station["pavement_suggested"] = grade_pct > unpaved_limit + 1e-6 station["pavement_grade_pct"] = round(grade_pct, 2) station["pavement_grade_limit_pct"] = round(unpaved_limit, 2) diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index 7b81e5c9..23c40cf7 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -122,6 +122,8 @@ class SummaryInput: # 토취(반입토) — 유토곡선이 낸 성토 부족분(다짐상태 ㎥)과 구간(2026-09-14 브레인 ①). borrow_m3: float = 0.0 borrow_sites: list[dict[str, Any]] = field(default_factory=list) + # 혼합석 부설 — `Engine_GravelSurfacing.gravel_surfacing` 한 벌(2026-09-15 브레인 포장 둘 ⓑ). + gravel: dict[str, Any] | None = None #: 토취(반입토) 줄 — 수량만 서고 금액은 사유(「줄은 서고 금액은 안 섬」). @@ -313,6 +315,32 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]: group="층따기", spec="백호우", unit="㎡", amount=slope.get("bench_cut_fill", 0.0) ) ) + if source.gravel is not None: + # 노면 — 혼합석 부설(11-4 ㎥ = 흐트러진 부피) · 사전 터파기는 토공 축(브레인 ①). + note = " · ".join(source.gravel.get("notes") or []) + loose = float(source.gravel.get("loose_m3") or 0.0) + dig = float(source.gravel.get("excavation_soil_m3") or 0.0) + zero = "물량 0 이라 내역에 안 세움 · " # 0 원 줄은 「없음」과 구별이 안 됨(구조물 줄과 같은 규칙) + rows.append( + SummaryRow( + group="혼합석부설", + spec="유압식백호우(0.7㎥)", + amount=loose, + note=f"{'' if loose > 0 else zero}" + f"면적 {float(source.gravel.get('area_m2') or 0.0):,.2f}㎡ · {note}", + in_bill=loose > 0, + ) + ) + rows.append( + SummaryRow( + group="혼합석 사전터파기", + spec="토사 · 기계(굴삭기)", + amount=dig, + note=f"{'' if dig > 0 else zero}" + f"토사 면적 {float(source.gravel.get('soil_area_m2') or 0.0):,.2f}㎡ × 0.10m", + in_bill=dig > 0, + ) + ) return rows diff --git a/B08_Quantity/B08_Quantity_Engine_GravelSurfacing.py b/B08_Quantity/B08_Quantity_Engine_GravelSurfacing.py new file mode 100644 index 00000000..50097a86 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_GravelSurfacing.py @@ -0,0 +1,163 @@ +"""혼합석(쇄석) 부설 — **법령 조건 자동 · 연약·습윤 칸 · 「비포장 전 구간」 고르개** (2026-09-15). + +근거 + ① 법령 별표2 Ⅰ.2.바.(2) — 종단 8% 초과 사질·점토 구간 · 8% 이하 연약·습윤 구간에 쇄석·자갈 부설 + (최소 요구 · 조건이 또렷함 ⇒ 기본) + ② 임도기술교본 3-2 — 「콘크리트 포장 구간 이외에는 혼합석 부설 다짐 후 0.10m 내외」 + (더 넓은 권장 ⇒ 고르개) + ③ 임도기술교본 10-1 · 부록 7-2 — 포설 전 10㎝ 터파기 · 적용범위 「노면의 토사구간」 + ④ 품셈 11-4 쇄석·혼합석 부설(㎥ · 백호 부설만) · 1-2-3 체적환산표(혼합석 줄 없음) +⚠ 면적은 측점 사이 평균(끝 둘 중 하나만 해당이면 반만) — 토적표 평균단면적법과 같은 결. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import _num + +LEGAL_GRADE_PCT = 8.0 +THICKNESS_SUGGESTED_M = 0.10 # 교본 3-2 「다짐 후 0.10m 내외」 +PRE_EXCAVATION_M = 0.10 # 교본 10-1 「포설 전 10㎝ 터파기」 +C_SUGGESTED, L_SUGGESTED = 0.85, 1.25 # 실무 소광 관측 + +NOTE_LEGAL = ( + "법령 별표2 Ⅰ.2.바.(2) 조건으로 자동 — 종단 8% 초과 토사 측점 + 8% 이하 중 연약·습윤 칸 구간" + " · 교본 3-2 는 「콘크리트 포장 구간 이외」 전부(산출 조건 「비포장 전 구간」으로 넓힘)" +) +NOTE_ALL = ( + "비포장 전 구간 — 임도기술교본 3-2 「콘크리트 포장 구간 이외에는 혼합석 부설」 · 법령 별표2" + " Ⅰ.2.바.(2) 는 종단 8% 초과 사질·점토 / 8% 이하 연약·습윤 구간만 요구" +) +NOTE_SOIL = ( + "법령은 사질·점토를 가르는데 우리 프리셋은 토사 하나 — 토사 전부를 해당으로 봄(암은 비해당)" +) +NOTE_GRADE = "종단 경사 = 종단 계획선에서 측점을 감싼 구간 경사 중 큰 값(B05 포장 제안과 같은 식)" +NOTE_WIDTH = "폭 = 차도 표준 폭 + 확폭(노견 제외 · 실무 소광 「혼합석부설 B=3.0」)" +NOTE_COMPACTION = ( + "다짐 줄 없음 — 품셈 11-4 는 부설만이고 산림품셈에 쇄석 다짐 공종이 없음" + " · 9-16-2 노체다짐은 「대규모 성토지 층다짐」 조건이라 안 이음" +) +NOTE_EXCAVATION = ( + "사전 터파기 — 교본 10-1 「포설 전 10㎝ 터파기」 · 부록 7-2 적용범위 「노면의 토사구간」이라" + " 토사 측점만 · 파낸 흙의 운반·사토는 유토곡선 밖이라 안 셈" +) + + +def _factor_note(c: float, l_factor: float, suggested: bool) -> str: + head = ( + f"C {c:g} · L {l_factor:g} — 제안값 실무 소광 관측" + if suggested + else f"C {c:g} · L {l_factor:g} — 산출 조건 칸" + ) + return ( + f"{head} · 자재 = 다짐 후 부피 ÷ C × L · 원문 표에 혼합석 줄 없음(품셈 1-2-3 가까운 줄" + " 역(礫) L 1.10~1.20 · C 1.05~1.10) · ⚠ 소광 C 0.85(다지면 줄어듦)와 원문 역 C(늘어남)는" + " 방향이 반대 — 같은 C 라도 뜻이 다를 수 있음" + ) + + +def _qualifies( + chainage: float, + design: dict[str, Any], + grades: dict[float, float], + soft_wet: list[tuple[float, float]], + all_unpaved: bool, +) -> bool | None: + """이 측점에 까는가 — 경사를 모르면 `None`(판정 못 함).""" + if design.get("paved"): + return False + if all_unpaved: + return True + grade = grades.get(round(chainage, 3)) + if grade is None: + return None + if grade > LEGAL_GRADE_PCT: + return design.get("ground_type") == "soil" + return any(start <= chainage <= end for start, end in soft_wet) + + +def gravel_surfacing( + designs: list[dict[str, Any]] | None, + grades: dict[float, float] | None, + settings: dict[str, Any], +) -> dict[str, Any]: + """노선 혼합석 부설 한 벌 — 면적·부피·사전 터파기·사유.""" + grades = {round(float(k), 3): float(v) for k, v in (grades or {}).items()} + all_unpaved = bool(settings.get("gravel_all_unpaved")) + soft_wet = [ + tuple(sorted((_num(r.get("from_m")), _num(r.get("to_m"))))) + for r in settings.get("gravel_soft_wet_ranges") or [] + if isinstance(r, dict) + ] + stations = sorted( + ( + (_num(item.get("chainage_m")), item["design"]) + for item in designs or () + if isinstance(item, dict) and isinstance(item.get("design"), dict) + ), + key=lambda row: row[0], + ) + weights: list[tuple[float, float, float]] = [] # (측점, 폭×해당, 폭×해당×토사) + unknown = 0 + for chainage, design in stations: + hit = _qualifies(chainage, design, grades, soft_wet, all_unpaved) + unknown += hit is None + width = ( + _num(design.get("carriageway_standard_width_m")) + + _num(design.get("widening_left_m")) + + _num(design.get("widening_right_m")) + ) + on = width if hit else 0.0 + weights.append((chainage, on, on if design.get("ground_type") == "soil" else 0.0)) + area = soil_area = 0.0 + for (s0, w0, t0), (s1, w1, t1) in zip(weights, weights[1:]): + area += (w0 + w1) / 2 * (s1 - s0) + soil_area += (t0 + t1) / 2 * (s1 - s0) + + thickness = _num(settings.get("gravel_thickness_m")) or THICKNESS_SUGGESTED_M + c = _num(settings.get("gravel_conversion_c")) or C_SUGGESTED + l_factor = _num(settings.get("gravel_conversion_l")) or L_SUGGESTED + suggested = not (settings.get("gravel_conversion_c") or settings.get("gravel_conversion_l")) + notes = [NOTE_ALL if all_unpaved else NOTE_LEGAL, NOTE_SOIL, NOTE_GRADE, NOTE_WIDTH] + notes.append( + f"두께(다짐 후) {thickness:g}m — " + + ( + "산출 조건 칸" + if settings.get("gravel_thickness_m") + else "제안값 교본 3-2 「0.10m 내외」" + ) + ) + notes += [_factor_note(c, l_factor, suggested), NOTE_COMPACTION, NOTE_EXCAVATION] + if unknown: + notes.append(f"종단 경사를 못 읽은 측점 {unknown}곳은 안 깜(판정 못 함)") + compacted = area * thickness + return { + "all_unpaved": all_unpaved, + "area_m2": area, + "soil_area_m2": soil_area, + "thickness_m": thickness, + "compacted_m3": compacted, + "loose_m3": compacted / c * l_factor, + "excavation_soil_m3": soil_area * PRE_EXCAVATION_M, + "notes": notes, + # 칸이 비면 쓰는 제안값 — 화면이 회색으로 보임(값의 정의처는 여기 한 곳). + "suggested": {"thickness_m": THICKNESS_SUGGESTED_M, "c": C_SUGGESTED, "l": L_SUGGESTED}, + } + + +def gravel_material_rows(result: dict[str, Any] | None) -> list[dict[str, Any]]: + """혼합석 자재(흐트러진 부피) → 자재총괄 성분 한 줄 — 부피가 없으면 빈 목록.""" + amount = _num((result or {}).get("loose_m3")) + if amount <= 0: + return [] + return [ + { + "name": "혼합석", + "spec": "", + "unit": "㎥", + "amount": amount, + "destination": "material", + "source": "혼합석 부설", + } + ] diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 2e65a6e1..f79ec52f 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -15,7 +15,9 @@ from __future__ import annotations import asyncio +import json import logging +from pathlib import Path from typing import Any from uuid import UUID @@ -40,6 +42,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( ) from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table +from B08_Quantity.B08_Quantity_Engine_GravelSurfacing import gravel_surfacing from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table from B08_Quantity.B08_Quantity_Engine_HaulSummary import ( @@ -162,8 +165,14 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: "by_equipment": check.details, } + # 혼합석 부설 — 법령 조건 자동(종단 8% = B05 측점 경사 · 토질 = 지반 프리셋) · 칸(2026-09-15). + gravel = gravel_surfacing( + designs, await _station_grades(project_id, route_id, project_root, designs), settings + ) + table["gravel"] = gravel table["summary"] = build_summary_table( SummaryInput( + gravel=gravel, earthwork_totals=table.get("totals") or {}, slope_totals=slope.get("totals") or {}, haul_rows=summary_input_rows(haul), @@ -471,6 +480,38 @@ async def _stored_haul_plan(project_id: UUID, route_id: int) -> dict[str, Any] | return plan if isinstance(plan, dict) and plan else None +async def _station_grades( + project_id: UUID, route_id: int, project_root: str | None, designs: list[dict[str, Any]] +) -> dict[float, float]: + """횡단 측점마다 종단 경사(%) — 종단 파일 계획선에서 B05 포장 제안과 **같은 식**으로. + + ⚠ 종단 파일 `stations` 의 `pavement_grade_pct` 는 정규 측점뿐(비정규 측점 85.052 등이 빠짐) — + 계획선에서 바로 셈. 못 읽으면 빈 표(혼합석 판정이 「경사 못 읽음」 사유). + """ + from B05_Profile.B05_Profile_Engine_Sections import local_grade_pct + + try: + row = await run_with_connection(get_longitudinal_section, project_id, route_id) + path = Path(str(project_root)) / str((row or {})["longitudinal_file_path"]) + profiles = json.loads(path.read_text(encoding="utf-8")).get("design_profiles") or [] + points = [ + (float(s["chainage_m"]), float(s["elevation_m"])) + for s in (profiles[0].get("samples") or [] if profiles else []) + if isinstance(s.get("chainage_m"), (int, float)) + and isinstance(s.get("elevation_m"), (int, float)) + ] + except Exception: + logger.warning("B08 혼합석 — 종단 계획선을 못 읽음: route_id=%s", route_id) + return {} + if len(points) < 2: + return {} + return { + round(float(item["chainage_m"]), 3): local_grade_pct(points, float(item["chainage_m"])) + for item in designs + if isinstance(item, dict) and isinstance(item.get("chainage_m"), (int, float)) + } + + @router.put("/{project_id}/quantity/settings") async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse: """산출 조건을 정본에 남긴다 — [저장]이 부르는 자리. @@ -517,6 +558,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - "ancillary_counts", # 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다. "conversion_factors_override", + "gravel_soft_wet_ranges", ) + NULLABLE_SETTING_KEYS, ) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork_Settings.py b/B08_Quantity/B08_Quantity_Router_Earthwork_Settings.py index 644b7380..f96d8008 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork_Settings.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork_Settings.py @@ -87,6 +87,14 @@ class QuantitySettingsBody(BaseModel): conversion_factors_override: dict[str, Any] | None = None # 도쟈 한계거리(m) — `None` 은 기본값(60 m). 종무대 20 m 보다 커야 한다(도쟈 몫이 사라짐). dozer_haul_limit_m: float | None = None + # 혼합석 부설(2026-09-15 브레인 포장 둘 ⓑ) — 「비포장 전 구간」 고르개(교본 3-2) · 연약·습윤 구간 + # `[{from_m, to_m}]`(법령 별표2 Ⅰ.2.바.(2) 8% 이하 조건 — 판정 근거가 없어 설계자 칸) · + # 두께(다짐 후)·C·L 은 비면 제안값(교본 0.10 · 소광 0.85/1.25). + gravel_all_unpaved: bool | None = None + gravel_soft_wet_ranges: list[dict[str, Any]] | None = None + gravel_thickness_m: float | None = None + gravel_conversion_c: float | None = None + gravel_conversion_l: float | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. @@ -102,6 +110,9 @@ NULLABLE_SETTING_KEYS = ( "tree_waste_unit_price_krw_per_ton", "face_dressing_fill_area_m2", "face_dressing_cut_area_m2", + "gravel_thickness_m", + "gravel_conversion_c", + "gravel_conversion_l", ) @@ -161,4 +172,14 @@ def clean_setting_values(values: dict[str, Any]) -> str | None: for name, method in values["rock_methods"].items() if method in ROCK_METHODS } + if "gravel_soft_wet_ranges" in values: + # 숫자 둘이 다 선 구간만 · 앞뒤를 바로잡음 — 빈 목록은 「구간 없음」으로 통째로 갈아 끼움. + values["gravel_soft_wet_ranges"] = [ + {"from_m": min(a, b), "to_m": max(a, b)} + for entry in values["gravel_soft_wet_ranges"] or [] + if isinstance(entry, dict) + for a, b in [(entry.get("from_m"), entry.get("to_m"))] + if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (a, b)) + and a != b + ] return None diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 255b0ba6..9f961fe1 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -45,6 +45,7 @@ from common_util.common_util_project_settings import ( rock_classes, rock_method, ) +from B08_Quantity.B08_Quantity_Engine_GravelSurfacing import gravel_material_rows from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ancillary_material_rows @@ -159,6 +160,7 @@ def material_table_for( unit_table: dict[str, Any], settings: dict[str, Any], preparation_table: dict[str, Any] | None, + gravel: dict[str, Any] | None = None, ) -> dict[str, Any]: """자재총괄 **한 벌** — 화면(`material-summary`)·인계(`handoff`)가 같이 부름(브레인 판정). @@ -180,7 +182,9 @@ def material_table_for( concrete_placing_method=settings.get("concrete_placing_method"), extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}) # 공종 없는 부대시설 개소 — 「자재 단가」 탭에 단가를 넣는 통로(2026-09-14 브레인 판정). - + ancillary_material_rows((preparation_table or {}).get("rows") or []), + + ancillary_material_rows((preparation_table or {}).get("rows") or []) + # 혼합석 자재(흐트러진 부피) — 11-4 는 부설 품뿐이라 재료는 여기서(2026-09-15). + + gravel_material_rows(gravel), ) @@ -223,7 +227,9 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: ) # 인계와 같은 한 벌 — 규준틀 재료가 준비공 표를 따라 서므로 토공 표를 받음. earthwork = await _earthwork_tables(project_id) - material_table = material_table_for(unit_table, settings, earthwork.get("preparation")) + material_table = material_table_for( + unit_table, settings, earthwork.get("preparation"), earthwork.get("gravel") + ) # 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다. handoff = build_handoff(unit_quantity_table=unit_table) composite = [ @@ -474,7 +480,9 @@ async def get_handoff(project_id: UUID) -> JSONResponse: # 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다. earthwork = await _earthwork_tables(project_id) # 자재총괄 — 화면과 같은 한 벌(규준틀 재료 포함). - material_table = material_table_for(unit_table, settings, earthwork.get("preparation")) + material_table = material_table_for( + unit_table, settings, earthwork.get("preparation"), earthwork.get("gravel") + ) handoff = build_handoff( summary_table=earthwork.get("summary"), diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index c65b2f2d..92ef64b5 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -168,6 +168,8 @@ export interface EarthworkTable { face_dressing_choices?: { cut: string[]; fill: string[] }; /** 초류종자살포 비탈면 토질 선택지(5-24 잎 둘) — 서버 한 곳 · 제안값 없음. */ seed_spray_choices?: { choices: string[] }; + /** 혼합석 부설 — 서버가 셈한 면적·부피·제안값(2026-09-15). 화면은 보이기만. */ + gravel?: import("./B08_Quantity_UI_Side_Gravel").GravelSummary; /** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */ root_removal_excavator_choices?: { choices: string[]; diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 8f353ac1..a68a30e7 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -44,6 +44,8 @@ import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste"; import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing"; import { appendRootRemovalFields } from "./B08_Quantity_UI_Side_RootRemoval"; import { appendSeedSprayField } from "./B08_Quantity_UI_Side_SeedSpray"; +import type { GravelDraft } from "./B08_Quantity_UI_Side_Gravel"; +import { appendGravelFields, gravelDraftFrom, gravelPayload } from "./B08_Quantity_UI_Side_Gravel"; import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut"; /** locale 헬퍼 */ @@ -133,6 +135,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr conversion_factors_override: conversionOverridePayload(draft.conversion_factors), // 도쟈 한계거리 — `null` 도 보낸다(기본값으로 되돌리는 길). dozer_haul_limit_m: draft.dozer_haul_limit_m, + ...gravelPayload(draft), // 혼합석 부설 — 칸은 `_Side_Gravel` }), }, ); @@ -285,7 +288,7 @@ export interface SupplyChoice { } /** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */ -interface DraftSettings { +interface DraftSettings extends GravelDraft { rock_class_set?: string; rock_ratios_pct: Record; application_ratios_pct: Record; @@ -733,6 +736,13 @@ function buildQuantitySidePanel( ), ); panel.append(hintRow(L("B08_Quantity_Side_SubgradeCompaction_Hint"))); + // ── 혼합석 부설 — 법령 조건 자동 · 연약·습윤 칸 · 전 구간 고르개(2026-09-15 브레인 ⓑ). + appendGravelFields(panel, draft, table?.gravel, { + field, + optionalNumberField, + selectField, + hintRow, + }); // ── 임목폐기물 — 조사값 넷 · 수동 처리단가 · 분리발주(2026-09-14). 칸은 따로 뺀 파일에서. appendTreeWasteFields(panel, draft, { field, optionalNumberField, selectField, hintRow }); @@ -1065,6 +1075,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { ]), ), dozer_haul_limit_m: (stored.dozer_haul_limit_m as number | null) ?? null, + ...gravelDraftFrom(stored as Record), dirty: false, }; const reload = (): void => { diff --git a/B08_Quantity/B08_Quantity_UI_Side_Gravel.ts b/B08_Quantity/B08_Quantity_UI_Side_Gravel.ts new file mode 100644 index 00000000..d640d3b5 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Side_Gravel.ts @@ -0,0 +1,159 @@ +/* ============================================================================= + * B08_Quantity_UI_Side_Gravel.ts + * 산출 조건 「혼합석 부설」 구획 — 범위 고르개 · 연약·습윤 구간 · 두께·C·L (2026-09-15 브레인 포장 둘 ⓑ). + * + * 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장). + * ⚠ 화면은 셈하지 않음 — 면적·부피는 서버 `gravel` 을 보이기만 · 제안값도 서버 `gravel.suggested`. + * ⚠ 빈 칸(`null`)은 「제안값으로」 — 0 과 다름. 구간 글자를 못 읽으면 저장값을 안 바꾸고 알림. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export interface GravelRange { + from_m: number; + to_m: number; +} + +export interface GravelDraft { + gravel_all_unpaved: boolean; + gravel_soft_wet_ranges: GravelRange[]; + gravel_thickness_m: number | null; + gravel_conversion_c: number | null; + gravel_conversion_l: number | null; +} + +/** 서버 `gravel_surfacing` 결과 중 화면이 보이는 몫. */ +export interface GravelSummary { + area_m2: number; + loose_m3: number; + excavation_soil_m3: number; + suggested?: { thickness_m: number; c: number; l: number }; +} + +export function gravelDraftFrom(stored: Record): GravelDraft { + const ranges = Array.isArray(stored.gravel_soft_wet_ranges) + ? (stored.gravel_soft_wet_ranges as GravelRange[]) + : []; + const num = (value: unknown) => (typeof value === "number" ? value : null); + return { + gravel_all_unpaved: Boolean(stored.gravel_all_unpaved), + gravel_soft_wet_ranges: ranges.map((r) => ({ from_m: r.from_m, to_m: r.to_m })), + gravel_thickness_m: num(stored.gravel_thickness_m), + gravel_conversion_c: num(stored.gravel_conversion_c), + gravel_conversion_l: num(stored.gravel_conversion_l), + }; +} + +/** [저장] 몸통 — 구간은 **통째로**(지운 구간까지 가야 되돌릴 길이 있음) · 숫자 `null` 도 보냄. */ +export function gravelPayload(draft: GravelDraft): GravelDraft { + return gravelDraftFrom(draft as unknown as Record); +} + +/** 「120~160, 480~520」 → 구간 목록. 한 조각이라도 못 읽으면 `null`(빈 글은 빈 목록). */ +export function parseGravelRanges(text: string): GravelRange[] | null { + const parts = text + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + const ranges: GravelRange[] = []; + for (const part of parts) { + const match = /^(\d+(?:\.\d+)?)\s*[~\-]\s*(\d+(?:\.\d+)?)$/.exec(part); + if (!match) return null; + const a = Number(match[1]); + const b = Number(match[2]); + if (a === b) return null; + ranges.push({ from_m: Math.min(a, b), to_m: Math.max(a, b) }); + } + return ranges; +} + +export function appendGravelFields( + panel: HTMLElement, + draft: GravelDraft & { dirty: boolean }, + summary: GravelSummary | undefined, + h: SideFieldHelpers, +): void { + panel.append(h.field(L("B08_Quantity_Side_Gravel"), "")); + panel.append( + h.selectField( + L("B08_Quantity_Gravel_Scope"), + draft.gravel_all_unpaved ? "all" : "", + [ + { value: "", label: L("B08_Quantity_Gravel_Scope_Legal") }, + { value: "all", label: L("B08_Quantity_Gravel_Scope_All") }, + ], + (value) => { + draft.gravel_all_unpaved = value === "all"; + draft.dirty = true; + }, + ), + ); + + const row = document.createElement("label"); + row.className = "b08-quantity__field"; + const name = document.createElement("span"); + name.textContent = L("B08_Quantity_Gravel_SoftWet"); + const input = document.createElement("input"); + input.type = "text"; + input.className = "b08-quantity__input"; + input.placeholder = L("B08_Quantity_Gravel_SoftWet_Placeholder"); + input.value = draft.gravel_soft_wet_ranges.map((r) => `${r.from_m}~${r.to_m}`).join(", "); + const warn = h.hintRow(""); + input.addEventListener("input", () => { + const parsed = parseGravelRanges(input.value); + warn.textContent = parsed ? "" : L("B08_Quantity_Gravel_SoftWet_Invalid"); + if (!parsed) return; + draft.gravel_soft_wet_ranges = parsed; + draft.dirty = true; + }); + row.append(name, input); + panel.append(row, warn); + + const suggested = summary?.suggested; + const number = ( + label: keyof typeof ui_locales, + value: number | null, + hint: number | undefined, + set: (v: number | null) => void, + ) => { + const field = h.optionalNumberField(L(label), value, "0.01", (typed) => { + set(typed); + draft.dirty = true; + }); + const box = field.querySelector("input"); + if (box && hint !== undefined) + box.placeholder = `${L("B08_Quantity_Gravel_Suggested")} ${hint}`; + return field; + }; + panel.append( + number( + "B08_Quantity_Gravel_Thickness", + draft.gravel_thickness_m, + suggested?.thickness_m, + (v) => { + draft.gravel_thickness_m = v; + }, + ), + number("B08_Quantity_Gravel_C", draft.gravel_conversion_c, suggested?.c, (v) => { + draft.gravel_conversion_c = v; + }), + number("B08_Quantity_Gravel_L", draft.gravel_conversion_l, suggested?.l, (v) => { + draft.gravel_conversion_l = v; + }), + ); + if (summary) { + const fmt = (value: number) => value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); + panel.append( + h.hintRow( + `${L("B08_Quantity_Gravel_Now")} — ${fmt(summary.area_m2)}㎡ · ${fmt(summary.loose_m3)}㎥` + + ` · ${L("B08_Quantity_Gravel_Excavation")} ${fmt(summary.excavation_soil_m3)}㎥`, + ), + ); + } + panel.append(h.hintRow(L("B08_Quantity_Side_Gravel_Hint"))); +} diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index efe83198..e3742874 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -181,6 +181,21 @@ "variant_missing_reason": "제근 굴착기 크기·임목축적 등급이 아직 다 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-21: 굴착기 0.2·0.7 × 소림·중림·밀림 · 크기 제안 0.7㎥ 산림품셈 10-12-1 [주]①)", "variant_note": "2026-09-14 브레인 판정 — 크기·등급 두 칸을 템플릿으로 한 값(「0.7·소림」)으로 엮고 B09 갈래 표기는 범위 별칭(FP-09-21)이 앎. 둘 중 하나라도 비면 등급만 싣고 입력 사유.", "note": "2026-09-13 브레인 판정 Ⓑ — 제근은 토공 줄(토공_수량.md:30 「뿌리다듬기·적재·제근 | 9-20~21 | 벌개제근 연동」)이라 여기서 셈. 준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만 보임(이중계상 막이). 품은 임목축적 등급(소림·중림·밀림, 9-21 [주]①)으로 갈려 등급을 갈래로 넘김." + }, + { + "group": "혼합석부설", + "work_item_code": "FP-11-04", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 11-4 쇄석·혼합석 부설 「(단위: ㎥당)」 · [주]① Q=3600×q×k×f×E/㎝ (f=1 — 버킷이 다루는 흐트러진 부피).", + "master_name": "쇄석·혼합석 부설", + "note": "2026-09-15 브레인 포장 둘 ⓑ — 법령 별표2 Ⅰ.2.바.(2) 조건으로 자동 · 「비포장 전 구간」 고르개(교본 3-2). 수량 = 다짐 후 부피 ÷ C × L(흐트러진 부피). 다짐 공종은 산림품셈에 없어 줄 없음." + }, + { + "group": "혼합석 사전터파기", + "work_item_code": "FP-09-03-02", + "basis_unit": "㎥", + "master_name": "토사깍기 > 기계", + "note": "2026-09-15 — 교본 10-1 「포설 전 10㎝ 터파기」 · 부록 7-2 「노면의 토사구간」이라 토사 측점만 · 브레인 ① 토공 축. ⚠ 노면 10㎝ 걷기를 토사깍기(기계)로 이음 — 산림품셈에 노면 사전 터파기 공종이 따로 없음(판정 확인 대기)." } ], "haul": [ diff --git a/resources/tester/test_b08_gravel_surfacing.py b/resources/tester/test_b08_gravel_surfacing.py new file mode 100644 index 00000000..ca01dda3 --- /dev/null +++ b/resources/tester/test_b08_gravel_surfacing.py @@ -0,0 +1,127 @@ +"""혼합석 부설 — 법령 조건 자동 · 연약·습윤 칸 · 「비포장 전 구간」 고르개 (2026-09-15 브레인 ⓑ). + +근거 + 법령 별표2 Ⅰ.2.바.(2) — 「종단기울기가 8퍼센트를 초과하는 사질토양 또는 점토질 토양인 구간과 + 종단기울기가 8퍼센트 이하인 구간으로서 지반이 약하고 습한 구간에는 쇄석·자갈을 부설」 + 임도기술교본 3-2 — 「콘크리트 포장 구간 이외에는 혼합석 부설 다짐 후 0.10m 내외」(고르개) + 임도기술교본 10-1 · 부록 7-2 — 포설 전 10㎝ 터파기 · 적용범위 「노면의 토사구간」 + 실무 소광 — 혼합석 C 0.85 · L 1.25 (원문 체적환산표에 혼합석 줄 없음 · + 역(礫) L 1.10~1.20 · C 1.05~1.10) +판정 — 토사 = 사질·점토 해당 · 암 = 비해당 · 폭 = 차도 표준 폭 + 확폭(노견 제외). +""" + +from __future__ import annotations + +import pytest + +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput, build_rows +from B08_Quantity.B08_Quantity_Engine_GravelSurfacing import gravel_material_rows, gravel_surfacing +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff + + +def _design(ground: str = "soil", widening: float = 0.0, paved: bool = False) -> dict: + return { + "ground_type": ground, + "carriageway_standard_width_m": 3.0, + "widening_left_m": widening, + "paved": paved, + } + + +DESIGNS = [ + {"chainage_m": 0.0, "design": _design()}, + {"chainage_m": 20.0, "design": _design()}, + {"chainage_m": 40.0, "design": _design(widening=1.0)}, + {"chainage_m": 60.0, "design": _design(paved=True)}, + {"chainage_m": 80.0, "design": _design(ground="ripping_rock")}, +] +GRADES = {0.0: 10.0, 20.0: 10.0, 40.0: 5.0, 60.0: 12.0, 80.0: 12.0} + + +def test_법령_조건만_자동_토사_8퍼센트_초과() -> None: + result = gravel_surfacing(DESIGNS, GRADES, {}) + # 0·20 만 해당 — [0,20] (3+3)/2×20 + [20,40] (3+0)/2×20 + assert result["area_m2"] == pytest.approx(90.0) + assert result["thickness_m"] == pytest.approx(0.10) + assert result["loose_m3"] == pytest.approx(90.0 * 0.10 * 1.25 / 0.85) + assert result["excavation_soil_m3"] == pytest.approx(9.0) + text = " ".join(result["notes"]) + assert "별표2" in text and "토사 전부를 해당으로 봄" in text + assert "원문 표에 혼합석 줄 없음" in text and "방향이 반대" in text + assert "다짐" in text + + +def test_연약_습윤_구간_칸이_8퍼센트_이하_측점을_더한다() -> None: + result = gravel_surfacing( + DESIGNS, GRADES, {"gravel_soft_wet_ranges": [{"from_m": 30, "to_m": 50}]} + ) + # 40 추가(경사 5 · 확폭 1) — [20,40] (3+4)/2×20 + [40,60] (4+0)/2×20 + assert result["area_m2"] == pytest.approx(60.0 + 70.0 + 40.0) + + +def test_비포장_전_구간_고르개는_포장만_뺀다_암은_사전터파기_안_셈() -> None: + result = gravel_surfacing(DESIGNS, GRADES, {"gravel_all_unpaved": True}) + assert result["area_m2"] == pytest.approx(60.0 + 70.0 + 40.0 + 30.0) + # 암 측점(80)은 교본 부록 7-2 「토사구간」 밖 — 사전 터파기 밑수에서 빠짐 + assert result["excavation_soil_m3"] == pytest.approx(170.0 * 0.10) + assert any("교본 3-2" in note for note in result["notes"]) + assert any("토사구간" in note for note in result["notes"]) + + +def test_칸이_두께_C_L_을_덮어쓴다() -> None: + settings = {"gravel_thickness_m": 0.2, "gravel_conversion_c": 1.0, "gravel_conversion_l": 1.2} + result = gravel_surfacing(DESIGNS, GRADES, settings) + assert result["loose_m3"] == pytest.approx(90.0 * 0.2 * 1.2 / 1.0) + + +def test_토공집계_두_줄과_인계_자재() -> None: + result = gravel_surfacing(DESIGNS, GRADES, {}) + rows = {row.group: row for row in build_rows(SummaryInput(gravel=result))} + assert rows["혼합석부설"].amount == pytest.approx(result["loose_m3"]) + assert "토사" in rows["혼합석 사전터파기"].spec + assert rows["혼합석 사전터파기"].amount == pytest.approx(9.0) + materials = gravel_material_rows(result) + assert materials == [ + { + "name": "혼합석", + "spec": "", + "unit": "㎥", + "amount": pytest.approx(result["loose_m3"]), + "destination": "material", + "source": "혼합석 부설", + } + ] + + +def test_산출_조건_저장이_구간을_거르고_자재총괄에_혼합석이_선다() -> None: + from B08_Quantity.B08_Quantity_Router_Earthwork_Settings import ( + NULLABLE_SETTING_KEYS, + clean_setting_values, + ) + from B08_Quantity.B08_Quantity_Router_Material import material_table_for + + values = {"gravel_soft_wet_ranges": [{"from_m": 50, "to_m": 30}, {"from_m": 1}, "x"]} + assert clean_setting_values(values) is None + assert values["gravel_soft_wet_ranges"] == [{"from_m": 30, "to_m": 50}] + assert {"gravel_thickness_m", "gravel_conversion_c", "gravel_conversion_l"} <= set( + NULLABLE_SETTING_KEYS + ) + gravel = gravel_surfacing(DESIGNS, GRADES, {}) + rows = material_table_for({"rows": []}, {}, None, gravel)["rows"] + assert [row for row in rows if row["name"] == "혼합석"] + + +def test_인계_코드() -> None: + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table + + result = gravel_surfacing(DESIGNS, GRADES, {}) + items = build_handoff(summary_table=build_table(SummaryInput(gravel=result)))["work_items"] + codes = {row["work_item_code"]: row for row in items if "혼합석" in str(row["name"])} + assert codes["FP-11-04"]["quantity"] == pytest.approx(result["loose_m3"]) + assert codes["FP-09-03-02"]["quantity"] == pytest.approx(9.0) + assert codes["FP-11-04"]["in_bill"] and codes["FP-09-03-02"]["in_bill"] + # 해당 측점이 없으면(모두 암) 0 원 줄을 안 세움 — 936be972 실측에서 잡음. + rock = [{**d, "design": {**d["design"], "ground_type": "ripping_rock"}} for d in DESIGNS] + empty = gravel_surfacing(rock, GRADES, {}) + zero = build_handoff(summary_table=build_table(SummaryInput(gravel=empty)))["work_items"] + assert not [r for r in zero if "혼합석" in str(r["name"]) and r["in_bill"]] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 696e7e1a..f5bd2680 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -111,4 +111,29 @@ export const ui_locales_b3 = { B09_Sheet_Rate_ClearAll: ["할증 전부 되돌리기", "Clear all surcharges"], B09_Sheet_Rate_SelectedCount: ["고른 줄", "Selected"], B09_Sheet_Rate_AllBadge: ["전체 할증", "Bill-wide"], + + /* --- B08 산출 조건 「혼합석 부설」(2026-09-15 브레인 포장 둘 ⓑ) --- */ + B08_Quantity_Side_Gravel: ["혼합석 부설", "Gravel Surfacing"], + B08_Quantity_Gravel_Scope: ["까는 범위", "Scope"], + B08_Quantity_Gravel_Scope_Legal: [ + "법령 조건(종단 8% 초과 토사 · 연약·습윤 구간)", + "Legal conditions (grade > 8% soil · soft/wet ranges)", + ], + B08_Quantity_Gravel_Scope_All: ["비포장 전 구간(교본 3-2)", "All unpaved stations (manual 3-2)"], + B08_Quantity_Gravel_SoftWet: ["연약·습윤 구간(m)", "Soft/wet ranges (m)"], + B08_Quantity_Gravel_SoftWet_Placeholder: ["예: 120~160, 480~520", "e.g. 120~160, 480~520"], + B08_Quantity_Gravel_SoftWet_Invalid: [ + "구간 글자를 못 읽음 — 「시작~끝」을 쉼표로 이어 적을 것", + "Cannot read ranges — write start~end separated by commas", + ], + B08_Quantity_Gravel_Thickness: ["두께(다짐 후, m)", "Thickness after compaction (m)"], + B08_Quantity_Gravel_C: ["C(다짐)", "C (compacted)"], + B08_Quantity_Gravel_L: ["L(흐트러짐)", "L (loose)"], + B08_Quantity_Gravel_Suggested: ["비우면 제안값", "Blank uses suggested"], + B08_Quantity_Gravel_Now: ["지금 셈", "Now"], + B08_Quantity_Gravel_Excavation: ["사전 터파기(토사)", "Pre-excavation (soil)"], + B08_Quantity_Side_Gravel_Hint: [ + "법령 별표2 Ⅰ.2.바.(2) 가 요구하는 구간만 자동 — 종단 8% 초과 사질·점토(우리 프리셋은 토사 하나라 토사 전부) · 8% 이하 연약·습윤은 칸(판정 근거 없음). 교본 3-2 는 「콘크리트 포장 구간 이외」 전부라 고르면 넓힘. 두께 제안 0.10(교본) · C·L 제안 소광 관측(원문 표에 혼합석 줄 없음 · 역 C 와 방향이 반대)", + "Only the stations required by the forest act table 2 are automatic — grade > 8% sandy/clay soil (our preset has one soil class) and ≤ 8% soft/wet ranges you enter. Manual 3-2 covers all non-concrete stations. Suggested thickness 0.10 (manual) · C·L from practice (no gravel row in the standard table)", + ], } as const; From 9578a537f0db660c5dd64911d64fd5f2d9d7da70 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 04:55:31 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore(plan):=20=EB=9E=A9=ED=83=91=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=20=EA=B3=84=ED=9A=8D=EC=84=9C=20=E2=80=94=20?= =?UTF-8?q?=ED=98=BC=ED=95=A9=EC=84=9D=20=EB=B6=80=EC=84=A4=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=C2=B7=20=EB=AC=BC=EC=9D=8C=20=EB=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- PLAN_랩탑서브.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PLAN_랩탑서브.md b/PLAN_랩탑서브.md index d66a9116..6972ef2a 100644 --- a/PLAN_랩탑서브.md +++ b/PLAN_랩탑서브.md @@ -46,6 +46,9 @@ ③ 연약·습윤 = 산출 조건 측점 구간 칸 · 「비포장 전 구간」 고르개 · 종단 8% = B05 `pavement_grade_pct` ④ 제안값 소광 관측 C 0.85 · L 1.25 · 원문 1-2-3 역(礫) L 1.10~1.20 · C 1.05~1.10 병기 · ⚠ 소광 C(줄어듦)와 역 C(늘어남)는 방향이 반대 — 사유에 적음 ⑤ 폭 = 차도 표준 폭 + 확폭(노견 제외 · 소광 「B=3.0」) + - [X] 토공집계 두 줄(혼합석부설 11-4 ㎥ = 다짐 후 ÷ C × L · 사전터파기 토사 9-3-2) · 자재총괄 혼합석 ㎥(사급) · 산출 조건 칸(범위 · 연약·습윤 구간 · 두께·C·L 비면 제안값) · 종단 경사는 계획선에서 B05 포장 제안과 같은 한 벌(`local_grade_pct` 로 뺌 — 종단 파일 측점 경사에 비정규 측점 8곳이 없었음) · 산출 조건 받는 모양은 `_Router_Earthwork_Settings` 로 가름 196bd73a. 검증완료(시험 · 936be972 화면 조작 후 되돌림) + - 936be972 — 법령 조건 0㎡(62측점 모두 리핑암 자리표시) · 전 구간 3,533.18㎡ · 519.58㎥ · 법령 + 연약·습윤 100~200 = 112.42㎡ · 되돌림(키는 false·[]·null 로 남음) + - ⚠ 물음 — B06 토사 토글 끔이면 측점 지반이 「리핑암」 자리표시라 법령 자동이 영영 0 · 사전터파기 공종 9-3-2 로 이은 것 확인 대기 - [ ] 녹화 — 교본 7-1 · 그림 7-8~7-12 「표준도」가 모식도인지 먼저 - [ ] 소단 — 임도 조문(사면길이 2~3m 마다 폭 50~100㎝) · 사방 기준 아님 - [ ] 개소형 — 대피소·작업장·차돌림곳·노출형 횡단수로