feat(B08): 토량환산계수를 프로젝트가 고를 수 있게 열되 기본값은 불변

- 기본값 정의처는 config_system_design 한 곳 그대로. 고른 값은 프로젝트 설정
  quantity.conversion_factors_override 에 얹고, earthwork_conversion_factors() 한 함수가
  기본값 위에 얹어 풀어 냄.
- 그 함수를 거치는 자리 여섯 — 토적표·운반표·기초단가(B08), 유토곡선 컨텍스트·
  배분 계산·[저장] 재계산(B06). 상수를 직접 드는 자리를 없앰.
- 산출 조건 패널에 갈래별 계수 칸 신설 — 기본값·품셈 범위를 함께 보이고,
  「유토곡선·운반표·기초단가에도 같이 닿음」 안내 한 줄. 범위 밖은 막지 않고 사유를 받음.
- 품셈 체적변화율 범위를 서버 상수로 두고 화면에 내려보냄(프론트에 다시 적지 않음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
This commit is contained in:
2026-09-12 13:38:00 +09:00
co-authored by Claude Opus 5
parent 1bfe1aa6e7
commit 465dbb955d
14 changed files with 608 additions and 60 deletions
@@ -36,6 +36,10 @@ from pathlib import Path
from typing import Any, Iterable
from common_util.common_util_json import atomic_write_json
from config.config_system_design import (
EARTHWORK_CONVERSION_C_RANGES,
EARTHWORK_CONVERSION_FACTORS,
)
SETTINGS_FILENAME = "project_settings.json"
SCHEMA_VERSION = 1
@@ -258,6 +262,62 @@ def concrete_placing_method(settings: dict[str, Any]) -> tuple[str, bool]:
return DEFAULT_CONCRETE_PLACING_METHOD, True
def earthwork_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]:
"""이 프로젝트가 쓸 토량환산계수 — **기본값 위에 고른 값만 얹는다.**
⚠ 정의처는 여전히 `config_system_design.EARTHWORK_CONVERSION_FACTORS` 한 곳이다.
여기서 값을 새로 적지 않고, 설계자가 고른 갈래만 갈아 끼운다. 안 고른 갈래는
키 자체가 없어 정본이 그대로 선다 — 기본값을 복사해 넣지 않는 까닭은 이 파일
머리글 `*_override` 규칙과 같다.
⚠ 이 값은 토적표만 쓰는 것이 아니다 — 유토곡선(B06)·운반표·기초단가가 같이 읽는다.
그래서 읽는 자리마다 상수를 직접 들지 말고 **이 함수를 거친다.**
고른 값의 모양 — `conversion_factors_override`
`{"ripping_rock": {"compacted": 1.0, "reason": "토질시험 값"}}`
`reason` 은 품셈 범위 밖을 골랐을 때 남기는 사유이고 계산에 안 쓴다.
"""
resolved = {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
override = settings.get("conversion_factors_override")
if not isinstance(override, dict):
return resolved
for kind, entry in override.items():
if kind not in resolved or not isinstance(entry, dict):
continue
value = entry.get("compacted")
if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value) > 0:
resolved[kind]["compacted"] = float(value)
return resolved
def earthwork_conversion_choices(settings: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""갈래별 「무엇을 골랐나」 — 화면이 기본값과 고른 값을 갈라 보이는 데 쓴다.
`{갈래: {"compacted", "default", "chosen", "in_range", "range", "reason"}}`.
`chosen` 이 거짓이면 기본값이 선 것이고, `in_range` 가 거짓이면 품셈 범위 밖이라
사유가 있어야 하는 자리다. **범위 밖이라고 막지 않는다**(품셈 원칙이 토질시험이다).
"""
override = settings.get("conversion_factors_override")
override = override if isinstance(override, dict) else {}
resolved = earthwork_conversion_factors(settings)
choices: dict[str, dict[str, Any]] = {}
for kind, entry in resolved.items():
default = float(EARTHWORK_CONVERSION_FACTORS[kind]["compacted"])
value = float(entry["compacted"])
low, high = EARTHWORK_CONVERSION_C_RANGES.get(kind, (None, None))
entry_override = override.get(kind)
reason = entry_override.get("reason") if isinstance(entry_override, dict) else None
choices[kind] = {
"compacted": value,
"default": default,
"chosen": value != default,
"in_range": low is None or low <= value <= high,
"range": [low, high] if low is not None else None,
"reason": str(reason) if reason else None,
}
return choices
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)