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