"""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 # 혼합석 부설(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` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. #: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `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", "gravel_thickness_m", "gravel_conversion_c", "gravel_conversion_l", ) 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 } 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