refactor(b08): 산출 조건 받는 모양·값 거르기를 _Router_Earthwork_Settings 로 가름 — 695줄 · 혼합석 칸 넣기 전 순수 분리

저장 자리(save_quantity_settings)는 라우터에 남김 · 이름은 다시 내보냄 · 소스 글자 시험 하나가 옮긴 파일을 봄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-15 04:18:14 +09:00
co-authored by Claude Opus 5
parent 527c1929cb
commit 196bd73a5b
3 changed files with 177 additions and 148 deletions
+8 -147
View File
@@ -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:
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
@@ -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
+5 -1
View File
@@ -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