feat(b08): 도자 한계거리를 산출 조건 칸으로 — 기본 60 m 와 근거 셋을 화면에 같이 보임

유토곡선 장비 경계의 도자 한계거리가 config 붙박이였고 설정 칸 haul_limits_m_override 는 아무도 안 읽었음.
dozer_haul_limit_m 칸으로 갈음해 B06 유토곡선 문맥·배분·B08 산출 조건이 같은 값을 읽음(common_util 한 곳).
화면에 기본값 60 m · 근거(품셈 8-1-1 · 산림과임업기술 5장 · 실무 EARTH.DAT 전수)를 보이고, 종무대 20 m 는 규정이라 값만 보임.
종무대 이하 값은 저장에서 막음 · 비우면 기본값.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-13 23:33:10 +09:00
co-authored by Claude Opus 5
parent f296b8c88c
commit 7bd1dc1e3a
11 changed files with 211 additions and 8 deletions
+2 -3
View File
@@ -73,14 +73,13 @@ from B06_Section.B06_Section_Schema import (
SectionRegenerateRequest,
SectionSummaryResponse,
)
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
FOREST_ROAD_MIN_WIDTH_M,
NATURAL_SPOIL_MIN_GROUND_SLOPE,
SECTION_VERTICAL_EXAGGERATION,
@@ -149,7 +148,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
earthwork_conversion=await conversion_factors_for(project_id),
haul_equipment_limits=[
HaulEquipmentLimit(key=key, max_distance_m=limit)
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in await haul_limits_for(project_id)
],
natural_spoil_min_ground_slope=NATURAL_SPOIL_MIN_GROUND_SLOPE,
)
+4 -1
View File
@@ -27,6 +27,7 @@ from B06_Section.B06_Section_Server_Calc_Prebuild import (
_mass_haul_context,
conversion_factors_for,
haul_inputs_for,
haul_limits_for,
)
from common_util.common_util_node_bundle import run_bundle_json
@@ -66,6 +67,8 @@ async def compute_haul_plan(
haul_inputs = await haul_inputs_for(project_id)
# 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다.
factors = await conversion_factors_for(project_id)
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
limits = await haul_limits_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
@@ -73,7 +76,7 @@ async def compute_haul_plan(
_NPM_SCRIPT,
{
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs, factors),
"context": _mass_haul_context(haul_inputs, factors, limits),
},
)
except Exception:
@@ -40,6 +40,7 @@ from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from common_util.common_util_node_bundle import run_bundle_json
from common_util.common_util_project_settings import (
earthwork_conversion_factors,
haul_equipment_limits,
quantity_settings,
)
from common_util.common_util_storage import resolve_stored_project_path
@@ -98,9 +99,21 @@ async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]
return earthwork_conversion_factors(quantity_settings(root))
async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]:
"""이 프로젝트가 쓸 운반장비 거리 경계(도쟈 한계거리를 고쳤으면 그 값). 못 읽으면 기본값."""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 경계로 진행: project_id=%s", project_id)
return list(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
return haul_equipment_limits(quantity_settings(root))
def _mass_haul_context(
haul_inputs: dict[str, Any] | None = None,
factors: dict[str, dict[str, float]] | None = None,
limits: list[tuple[str, float | None]] | None = None,
) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
@@ -118,9 +131,10 @@ def _mass_haul_context(
# 프로젝트가 고른 계수가 있으면 그것, 없으면 정본 기본값.
"earthwork_conversion": factors or EARTHWORK_CONVERSION_FACTORS,
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
# 프로젝트가 도쟈 한계거리를 고쳤으면 그 값, 없으면 정본 기본값.
"haul_equipment_limits": [
{"key": key, "max_distance_m": limit}
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in (limits or EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
],
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
@@ -220,7 +234,9 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
{
"detail": detail,
"context": _mass_haul_context(
haul_inputs, earthwork_conversion_factors(quantity_settings(project_root))
haul_inputs,
earthwork_conversion_factors(quantity_settings(project_root)),
haul_equipment_limits(quantity_settings(project_root)),
),
},
)
@@ -48,6 +48,7 @@ from common_util.common_util_project_settings import (
concrete_placing_method,
earthwork_conversion_choices,
earthwork_conversion_factors,
haul_limit_choice,
quantity_settings,
rock_classes,
save_section,
@@ -57,6 +58,7 @@ 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__)
@@ -90,6 +92,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
table = build_table(_stations(designs), factors)
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
# 도쟈 한계거리 — 지금 값·기본값·근거를 함께 보인다(유토곡선 장비 경계, 2026-09-13 판정).
table["haul_limit_choice"] = haul_limit_choice(settings)
# 품셈 암종별 범위 — **화면 안내용**이다. 정의처가 서버 한 곳이라 내려보내 쓴다
# (프론트에 다시 적으면 두 벌이 되어 갈린다).
table["conversion_factor_pumsem_ranges"] = [
@@ -445,6 +449,8 @@ class QuantitySettingsBody(BaseModel):
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
conversion_factors_override: dict[str, Any] | None = None
# 도쟈 한계거리(m) — `None` 은 기본값(60 m). 종무대 20 m 보다 커야 한다(도쟈 몫이 사라짐).
dozer_haul_limit_m: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
@@ -456,6 +462,7 @@ NULLABLE_SETTING_KEYS = (
"rubble_base_thickness_m",
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
"dozer_haul_limit_m",
)
@@ -482,6 +489,17 @@ 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:
# 조용히 기본값으로 돌리지 않는다 — 넣은 값이 안 쓰이는 줄 모른다.
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"도쟈 한계거리는 종무대 {free_haul:g} m 보다 커야 합니다.",
},
)
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
@@ -92,6 +92,8 @@ export interface QuantitySettings {
structure_trench_water?: string | null;
/** 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. */
topsoil_haul_distance_m?: number | null;
/** 도쟈 한계거리(m) — `null`·없음이면 기본값(서버 정본 60 m)이 선다. */
dozer_haul_limit_m?: number | null;
/** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */
ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
@@ -141,6 +143,15 @@ export interface EarthworkTable {
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
conversion_factor_pumsem_ranges?: PumsemRange[];
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */
haul_limit_choice?: {
value: number;
default: number;
chosen: boolean;
basis: string;
free_haul_m: number;
free_haul_basis: string;
};
/** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */
provenance?: ProvenancePayload;
}
+29
View File
@@ -110,6 +110,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
ancillary_counts: draft.ancillary_counts,
// 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다.
conversion_factors_override: conversionOverridePayload(draft.conversion_factors),
// 도쟈 한계거리 — `null` 도 보낸다(기본값으로 되돌리는 길).
dozer_haul_limit_m: draft.dozer_haul_limit_m,
}),
},
);
@@ -295,6 +297,8 @@ interface DraftSettings {
material_supply: Record<string, SupplyChoice>;
// 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다.
conversion_factors: Record<string, FactorDraft>;
// 도쟈 한계거리(m) — `null` 은 기본값(서버 정본 60 m)이 선다.
dozer_haul_limit_m: number | null;
dirty: boolean;
}
@@ -403,6 +407,30 @@ function buildQuantitySidePanel(
);
if (factorFields) panel.append(factorFields);
// ── 운반장비 거리 경계 — 도쟈 한계거리는 설계 조건이라 칸으로(2026-09-13 판정) ──
// ⚠ 기본값과 근거를 **서버가 준 그대로** 옆에 보인다 — 「이 값이 어디서 왔나」가 화면에 있어야 함.
const haul = table?.haul_limit_choice;
if (haul) {
panel.append(field(L("B08_Quantity_Side_HaulLimits"), ""));
const dozer = optionalNumberField(
L("B08_Quantity_Side_DozerLimit_Label"),
draft.dozer_haul_limit_m,
"5",
(value) => {
draft.dozer_haul_limit_m = value;
draft.dirty = true;
},
);
const input = dozer.querySelector("input");
if (input) input.placeholder = String(haul.default);
panel.append(dozer);
panel.append(hintRow(`${L("B08_Quantity_Factor_Default")} ${haul.default} m — ${haul.basis}`));
panel.append(
hintRow(`${L("B08_Quantity_Side_FreeHaul")} ${haul.free_haul_m} m — ${haul.free_haul_basis}`),
);
panel.append(hintRow(L("B08_Quantity_Side_HaulLimits_Reach")));
}
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
const classes = [...(table?.summary?.rock_classes ?? [])];
// ⚠ 비율을 아직 안 넣었으면 집계가 **「암」 한 줄**로 나온다(갈래로 안 갈림). 그 줄에도
@@ -940,6 +968,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
},
]),
),
dozer_haul_limit_m: (stored.dozer_haul_limit_m as number | null) ?? null,
dirty: false,
};
const reload = (): void => {
+38 -1
View File
@@ -39,6 +39,8 @@ from common_util.common_util_json import atomic_write_json
from config.config_system_design import (
EARTHWORK_CONVERSION_C_RANGES,
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
EARTHWORK_HAUL_LIMIT_BASIS,
)
SETTINGS_FILENAME = "project_settings.json"
@@ -90,7 +92,10 @@ def default_settings() -> dict[str, Any]:
# 「시공법 미지정」으로 드러난다(2026-09-07 일감 9 에서 드러난 자리).
"rock_methods": {},
"conversion_factors_override": None,
"haul_limits_m_override": None,
# 도쟈 한계거리(m) — `None` 은 기본값(config 60 m, 근거 셋 일치)이 선다.
# 설계 조건이라 바꿀 수 있다(2026-09-13 판정). 종무대 20 m 는 규정이라 칸이 없다.
# ⚠ 종전 `haul_limits_m_override` 는 아무도 안 읽던 칸이라 이것으로 갈음했다.
"dozer_haul_limit_m": None,
"application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS},
# 자재총괄의 관급/사급 구분 — `{자재명: "owner_supplied"|"contractor_supplied"}`
# 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`.
@@ -318,6 +323,38 @@ def earthwork_conversion_choices(settings: dict[str, Any]) -> dict[str, dict[str
return choices
def haul_equipment_limits(settings: dict[str, Any]) -> list[tuple[str, float | None]]:
"""이 프로젝트가 쓸 운반장비 거리 경계 — 기본값 위에 **도쟈 한계거리만** 얹는다.
유토곡선(B06)·운반표가 같은 값으로 서야 한다 읽는 자리마다 상수를 들지 말고
함수를 거친다.
종무대 경계 이하 값은 받지 않는다(도쟈 몫이 사라짐) 기본값이 선다.
"""
limits = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
value = settings.get("dozer_haul_limit_m")
if (
isinstance(value, (int, float))
and not isinstance(value, bool)
and float(value) > float(limits["free_haul"] or 0.0)
):
limits["dozer"] = float(value)
return [(key, limits[key]) for key, _ in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M]
def haul_limit_choice(settings: dict[str, Any]) -> dict[str, Any]:
"""도쟈 한계거리 칸 — 화면이 지금 값·기본값·근거를 함께 보인다."""
default = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["dozer"]
value = dict(haul_equipment_limits(settings))["dozer"]
return {
"value": value,
"default": default,
"chosen": value != default,
"basis": EARTHWORK_HAUL_LIMIT_BASIS["dozer"],
"free_haul_m": dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"],
"free_haul_basis": EARTHWORK_HAUL_LIMIT_BASIS["free_haul"],
}
def application_ratio(settings: dict[str, Any], key: str) -> float:
"""반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다."""
raw = (settings.get("application_ratios_pct") or {}).get(key, 100)
+10
View File
@@ -422,6 +422,16 @@ EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = (
("dozer", 60.0),
("dump_truck", None),
)
# 경계값 근거 — **화면이 기본값 옆에 그대로 보인다**(2026-09-13 브레인 판정 · PLAN 10장).
# ⚠ 성격이 다르다: 종무대 20 m 는 품셈 규정이라 칸을 안 두고, 도쟈 60 m 는 설계 조건이라
# 프로젝트 설정 `dozer_haul_limit_m` 으로 바꿀 수 있다.
EARTHWORK_HAUL_LIMIT_BASIS = {
"free_haul": "품셈 1-2-7 「소운반 20 m 이내는 품에 포함」 — 규정이라 안 바꿈",
"dozer": (
"근거 셋 일치 — 표준품셈 8-1-1(60 m 이하 도저) · 산림과임업기술 5장 p.441·478"
"(60 m 이하 도저 / 초과 덤프) · 실무 오솔길 EARTH.DAT 6개 공사지 전부 60 m"
),
}
# ─────────────────────────────────────────────────────────────────────────
@@ -239,7 +239,8 @@ def test_기본설정_모양(tmp_path: Path) -> None:
quantity = settings["quantity"]
# override 는 기본이 None — 「안 정했으면 config 정본을 쓴다」는 뜻.
assert quantity["conversion_factors_override"] is None
assert quantity["haul_limits_m_override"] is None
# 도쟈 한계거리 — 안 읽히던 `haul_limits_m_override` 를 갈음(2026-09-13). None 이면 기본 60 m.
assert quantity["dozer_haul_limit_m"] is None
# 반영률 기본 100. 실무 관측 80/50/80 을 넣지 않는다.
assert quantity["application_ratios_pct"] == {key: 100 for key in APPLICATION_RATIO_KEYS}
# estimation 은 자리만 — 채우는 것은 B09 몫.
@@ -0,0 +1,72 @@
"""도쟈 한계거리 칸 (PLAN 5장 · 2026-09-13 판정 「도자 60 m 확정 — 설계 조건이라 칸으로」).
지키는
넣으면 정본 60 m · 넣으면 유토곡선 장비 경계가 · 종무대 20 m 규정이라 바뀜
화면이 기본값과 근거(근거 일치) 함께 받음
종무대 이하 값은 저장에서 막음(도쟈 몫이 사라짐) · null 기본값 되돌림
B06 유토곡선 계산 문맥이 프로젝트 값을 실음
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Router_Earthwork as earthwork_router # noqa: E402
from B06_Section.B06_Section_Server_Calc_Prebuild import _mass_haul_context # noqa: E402
from common_util.common_util_project_settings import ( # noqa: E402
haul_equipment_limits,
haul_limit_choice,
load_settings,
)
PROJECT_ID = "55555555-5555-5555-5555-555555555555"
def test_기본은_60m_넣으면_그_값_종무대는_그대로() -> None:
assert haul_equipment_limits({}) == [("free_haul", 20.0), ("dozer", 60.0), ("dump_truck", None)]
assert dict(haul_equipment_limits({"dozer_haul_limit_m": 70}))["dozer"] == 70.0
assert dict(haul_equipment_limits({"dozer_haul_limit_m": 15}))["dozer"] == 60.0 # 종무대 이하
choice = haul_limit_choice({"dozer_haul_limit_m": 70})
assert (choice["value"], choice["default"], choice["chosen"]) == (70.0, 60.0, True)
assert "8-1-1" in choice["basis"] and "EARTH.DAT" in choice["basis"]
assert "1-2-7" in choice["free_haul_basis"]
def test_유토곡선_문맥이_프로젝트_경계를_싣는다() -> None:
limits = haul_equipment_limits({"dozer_haul_limit_m": 80})
context = _mass_haul_context(None, None, limits)
assert {row["key"]: row["max_distance_m"] for row in context["haul_equipment_limits"]} == {
"free_haul": 20.0,
"dozer": 80.0,
"dump_truck": None,
}
assert _mass_haul_context()["haul_equipment_limits"][1]["max_distance_m"] == 60.0
@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
async def fake_run(func, *args):
return "project"
monkeypatch.setattr(earthwork_router, "run_with_connection", fake_run)
monkeypatch.setattr(earthwork_router, "resolve_stored_project_path", lambda _p: str(tmp_path))
app = FastAPI()
app.include_router(earthwork_router.router)
return TestClient(app)
def test_저장은_종무대_이하를_막고_null_로_되돌린다(client: TestClient, tmp_path: Path) -> None:
url = f"/api/projects/{PROJECT_ID}/quantity/settings"
assert client.put(url, json={"dozer_haul_limit_m": 20}).status_code == 400
assert client.put(url, json={"dozer_haul_limit_m": 70}).status_code == 200
assert load_settings(tmp_path)["quantity"]["dozer_haul_limit_m"] == 70
assert client.put(url, json={"dozer_haul_limit_m": None}).status_code == 200
assert load_settings(tmp_path)["quantity"]["dozer_haul_limit_m"] is None
+7
View File
@@ -765,6 +765,13 @@ export const ui_locales_b2 = {
"These factors also feed the mass-haul curve, haul table and basis units.",
],
B08_Quantity_Factor_Default: ["기본값", "Default"],
B08_Quantity_Side_HaulLimits: ["운반장비 거리 경계", "Haul equipment distance limits"],
B08_Quantity_Side_DozerLimit_Label: ["도쟈 한계거리(m)", "Dozer haul limit (m)"],
B08_Quantity_Side_FreeHaul: ["종무대(바꾸지 않음)", "Free haul (fixed)"],
B08_Quantity_Side_HaulLimits_Reach: [
"유토곡선 운반 배분(무대·도쟈·덤프)에 닿습니다 — 바꾼 뒤 종횡단에서 다시 저장해야 운반표가 따라옵니다.",
"Feeds the mass-haul split (free/dozer/dump) — re-save the profile/cross sections for the haul table to follow.",
],
B08_Quantity_Factor_Range: ["품셈 범위", "Standard range"],
B08_Quantity_Factor_OutOfRange: [
"품셈 범위 밖입니다 — 사유를 적어 주세요(토질시험 값일 수 있습니다).",