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
+4 -2
View File
@@ -73,13 +73,13 @@ from B06_Section.B06_Section_Schema import (
SectionRegenerateRequest,
SectionSummaryResponse,
)
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_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_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
FOREST_ROAD_MIN_WIDTH_M,
NATURAL_SPOIL_MIN_GROUND_SLOPE,
@@ -144,7 +144,9 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
stored_standard_cross_section=stored_standard,
rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M,
earthwork_conversion=EARTHWORK_CONVERSION_FACTORS,
# ⚠ 상수를 직접 들지 않는다 — 프로젝트가 고른 계수가 있으면 화면도 그 값으로
# 그려야 서버가 뒤에 다시 셈한 값과 갈리지 않는다(CLAUDE.md 5장).
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
+4 -1
View File
@@ -25,6 +25,7 @@ from fastapi.responses import JSONResponse
from B06_Section.B06_Section_Server_Calc_Prebuild import (
BUNDLE,
_mass_haul_context,
conversion_factors_for,
haul_inputs_for,
)
from common_util.common_util_node_bundle import run_bundle_json
@@ -63,6 +64,8 @@ async def compute_haul_plan(
# 구조물 몫(공제·잔토)을 **넘겨야** 사토가 줄고 는다 — 인자 없이 부르면 늘 `None` 이라
# 통로만 있고 값이 안 흐른다(2026-09-09 실측으로 드러난 자리).
haul_inputs = await haul_inputs_for(project_id)
# 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다.
factors = await conversion_factors_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
@@ -70,7 +73,7 @@ async def compute_haul_plan(
_NPM_SCRIPT,
{
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs),
"context": _mass_haul_context(haul_inputs, factors),
},
)
except Exception:
@@ -38,6 +38,10 @@ from B06_Section.B06_Section_Repository import (
)
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,
quantity_settings,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
@@ -79,7 +83,25 @@ async def haul_inputs_for(project_id: Any) -> dict[str, Any]:
return {}
def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, Any]:
async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]:
"""이 프로젝트가 쓸 토량환산계수. 못 읽으면 정본 기본값 — 화면은 그대로 선다.
⚠ 곡선·운반·토적표가 **같은 계수**로 서야 한다. 그래서 상수를 직접 들지 않고 이 함수를
거친다(고른 값은 프로젝트 설정 `conversion_factors_override` 에 산다).
"""
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 {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
return earthwork_conversion_factors(quantity_settings(root))
def _mass_haul_context(
haul_inputs: dict[str, Any] | None = None,
factors: dict[str, dict[str, float]] | None = None,
) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
⚠ 채집석 공제(`collected_stone_deduction_m3`)만 상수가 아니라 **B08 이 내는 값**이다.
@@ -93,7 +115,8 @@ def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, A
"""
inputs = haul_inputs or {}
return {
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
# 프로젝트가 고른 계수가 있으면 그것, 없으면 정본 기본값.
"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}
@@ -196,7 +219,9 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
_NPM_SCRIPT,
{
"detail": detail,
"context": _mass_haul_context(haul_inputs),
"context": _mass_haul_context(
haul_inputs, earthwork_conversion_factors(quantity_settings(project_root))
),
},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
@@ -16,7 +16,8 @@
보정량 = 체적 × 토량환산계수(다짐)
절취한 흙이 다져지면 줄거나 부푼다. 성토에 쓸 수 있는 양으로 환산한 것이 보정량이다.
계수의 유일한 정의처는 `config.config_system_design.EARTHWORK_CONVERSION_FACTORS` 이며
여기서 값을 다시 적지 않는다.
여기서 값을 다시 적지 않는다. 프로젝트가 고른 값이 있으면 라우터가
`earthwork_conversion_factors(settings)` 로 풀어 `factors` 로 넘긴다 — 기본값은 그대로다.
측구터파기 토사·암 — 설계가 가른 값을 그대로 읽는다
B06 이 지반 유형 + 암반 경계선으로 이미 갈라 냈다(`ditch_soil_area_m2`·
@@ -49,9 +50,13 @@ _FALLBACK_BASIS = "cut_area_ratio_fallback"
_FALLBACK_NOTE = "측구 가름값이 설계에 없어 절토 토사:암 면적비로 안분함"
def _factor(kind: str) -> float:
#: 계수 묶음의 모양 — `{지반유형: {"compacted": C}}`.
Factors = dict[str, dict[str, float]]
def _factor(kind: str, factors: Factors) -> float:
"""지반유형 → 다짐 환산계수. 모르는 유형이면 토사로 본다."""
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) or EARTHWORK_CONVERSION_FACTORS["soil"]
entry = factors.get(kind) or factors["soil"]
return float(entry["compacted"])
@@ -155,8 +160,15 @@ def _split_ditch(area: StationArea) -> tuple[float, float, str]:
return ditch * soil / total, ditch * rock / total, _FALLBACK_BASIS
def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]:
"""측점 목록 → 토적표 줄 목록. 측점은 이정 순으로 정렬해 받는다."""
def build_rows(
stations: Iterable[StationArea], factors: Factors | None = None
) -> list[EarthworkRow]:
"""측점 목록 → 토적표 줄 목록. 측점은 이정 순으로 정렬해 받는다.
`factors` 는 프로젝트가 고른 토량환산계수다(`earthwork_conversion_factors`).
안 주면 정본 기본값이 선다 — 설정을 안 읽는 자리(시험·되짚기)를 위한 것이다.
"""
factors = factors or EARTHWORK_CONVERSION_FACTORS
ordered = sorted(stations, key=lambda s: s.chainage_m)
rows: list[EarthworkRow] = []
previous: StationArea | None = None
@@ -165,8 +177,8 @@ def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]:
for station in ordered:
ditch_soil, ditch_rock, ditch_basis = _split_ditch(station)
soil_factor = _factor("soil")
rock_factor = _factor(station.cut_rock_kind or _DEFAULT_ROCK_KIND)
soil_factor = _factor("soil", factors)
rock_factor = _factor(station.cut_rock_kind or _DEFAULT_ROCK_KIND, factors)
row = EarthworkRow(
chainage_m=station.chainage_m,
cut_soil_area_m2=station.cut_soil_area_m2,
@@ -238,12 +250,17 @@ def totals(rows: list[EarthworkRow]) -> dict[str, float]:
return {key: sum(getattr(row, key) for row in rows) for key in keys}
def build_table(stations: Iterable[StationArea]) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 값은 자르지 않는다(PLAN 8-16)."""
rows = build_rows(stations)
def build_table(stations: Iterable[StationArea], factors: Factors | None = None) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 값은 자르지 않는다(PLAN 8-16).
`conversion_factors` 로 **실제로 쓴 계수**를 되싣는다 — 프로젝트가 고른 값이면
그것이 나가야 화면이 「무엇으로 셌나」를 그대로 보인다.
"""
factors = factors or EARTHWORK_CONVERSION_FACTORS
rows = build_rows(stations, factors)
return {
"method": "average_end_area",
"conversion_factors": EARTHWORK_CONVERSION_FACTORS,
"conversion_factors": factors,
"rows": [row.__dict__ if not hasattr(row, "__slots__") else _as_dict(row) for row in rows],
"totals": totals(rows),
"station_count": len(rows),
@@ -43,14 +43,24 @@ GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"}
GROUND_KIND_OF = {"토사": "soil", "리핑암": "ripping_rock", "발파암": "blasting_rock"}
def _factor_of(ground: str) -> float | None:
"""그 갈래의 다짐 환산계수 `C`. 모르면 `None`(받는 쪽이 환산했는지 되짚는 데 쓴다)."""
#: 계수 묶음의 모양 — `{지반유형: {"compacted": C}}`. 안 주면 정본 기본값이 선다.
Factors = dict[str, dict[str, float]]
def _factor_of(ground: str, factors: Factors | None = None) -> float | None:
"""그 갈래의 다짐 환산계수 `C`. 모르면 `None`(받는 쪽이 환산했는지 되짚는 데 쓴다).
`factors` 는 프로젝트가 고른 계수다(`earthwork_conversion_factors`) — 안 주면 정본.
"""
kind = GROUND_KIND_OF.get(ground)
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
table = factors or EARTHWORK_CONVERSION_FACTORS
entry = table.get(kind) if kind else None
return float(entry["compacted"]) if entry else None
def natural_m3(compacted_volume_m3: float, ground: str) -> float | None:
def natural_m3(
compacted_volume_m3: float, ground: str, factors: Factors | None = None
) -> float | None:
"""**다짐상태 → 자연상태**(÷ C). 내역서에 오르는 수량은 자연상태다.
근거 — `config_system_design` 5-4-3 에 이미 적혀 있던 문장이다.
@@ -69,7 +79,8 @@ def natural_m3(compacted_volume_m3: float, ground: str) -> float | None:
⚠ 갈래를 모르면 `None` 이다 — 토사 계수로 눅이면 근거 없이 금액이 움직인다.
"""
kind = GROUND_KIND_OF.get(ground)
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
table = factors or EARTHWORK_CONVERSION_FACTORS
entry = table.get(kind) if kind else None
if not entry:
return None
factor = float(entry["compacted"])
@@ -189,8 +200,11 @@ def summarize(legs: Iterable[HaulLeg]) -> list[HaulSummaryRow]:
)
def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다."""
def build_table(plan: dict[str, Any] | None, factors: Factors | None = None) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다.
`factors` 는 프로젝트가 고른 토량환산계수다 — 다짐 → 자연 되돌리기가 이 값에 걸린다.
"""
legs = _legs_of(plan or {})
rows = summarize(legs)
return {
@@ -203,9 +217,9 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
"volume_m3": row.volume_m3,
"volume_basis": "compacted",
# 내역서에 오르는 수량 = **자연상태**(÷C). 갈래를 모르면 `None`.
"natural_m3": natural_m3(row.volume_m3, row.ground),
"natural_m3": natural_m3(row.volume_m3, row.ground, factors),
"natural_volume_basis": "natural",
"conversion_c": _factor_of(row.ground),
"conversion_c": _factor_of(row.ground, factors),
"average_distance_m": row.average_distance_m,
"work_m3m": row.work_m3m,
"legs": row.legs,
+57 -13
View File
@@ -45,13 +45,18 @@ from common_util.common_util_project_settings import (
ROCK_METHODS,
application_ratio,
concrete_placing_method,
earthwork_conversion_choices,
earthwork_conversion_factors,
quantity_settings,
rock_classes,
save_section,
)
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
from config.config_system_design import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_CONVERSION_PUMSEM_C_RANGES,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
@@ -78,14 +83,24 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
status_code=500,
content={"status": "error", "message": "토적표를 만들지 못했습니다."},
)
table = build_table(_stations(designs))
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
settings, project_root = await _project_settings(project_id)
factors = earthwork_conversion_factors(settings)
table = build_table(_stations(designs), factors)
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
# 품셈 암종별 범위 — **화면 안내용**이다. 정의처가 서버 한 곳이라 내려보내 쓴다
# (프론트에 다시 적으면 두 벌이 되어 갈린다).
table["conversion_factor_pumsem_ranges"] = [
{"name": name, "min": low, "max": high}
for name, low, high in EARTHWORK_CONVERSION_PUMSEM_C_RANGES
]
# 사면 계열은 저장된 설계선에서 유도한다.
slope = build_slope_table(station_slopes(designs))
table["slope"] = slope
settings, project_root = await _project_settings(project_id)
plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan)
haul = build_haul_table(plan, factors)
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
@@ -164,12 +179,14 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
return JSONResponse(content=table)
#: 갈래 칸 ↔ 다짐 환산계수 `C`. 정의처는 `config_system_design` 한 곳뿐이다.
_COMPACTED_FACTOR = {
"ea_m3": float(EARTHWORK_CONVERSION_FACTORS["soil"]["compacted"]),
"rr_m3": float(EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"]),
"br_m3": float(EARTHWORK_CONVERSION_FACTORS["blasting_rock"]["compacted"]),
}
#: 갈래 칸 ↔ 지반유형 이름. 계수의 정의처는 `config_system_design` 한 곳뿐이다.
_GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_rock"}
def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]:
"""갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다."""
factors = earthwork_conversion_factors(settings)
return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()}
def _spoil_sites(designs: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -295,10 +312,11 @@ def _spoil_of(
# 여기서 ÷C 한 값을 함께 내 받는 쪽이 **또 환산하지 않게** 한다.
# ⚠ 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않는다** — 토사 계수로 눅이면 근거 없이
# 금액이 움직인다. 그 사실을 사유로 낸다.
compacted_factor = _compacted_factor(settings)
natural_by_ground = {
key: round(value / _COMPACTED_FACTOR[key], 3)
key: round(value / compacted_factor[key], 3)
for key, value in grounds.items()
if key in _COMPACTED_FACTOR
if key in compacted_factor
}
if unknown > 0:
note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림")
@@ -417,6 +435,10 @@ class QuantitySettingsBody(BaseModel):
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
wood_chipping_enabled: bool | None = None
wood_chipping_volume_m3: float | None = None
# 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`.
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
conversion_factors_override: dict[str, Any] | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
@@ -458,6 +480,21 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS 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:
# 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로
# 여기서 버리면 그 갈래는 미지정으로 돌아간다.
@@ -473,7 +510,14 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
_save_quantity,
root,
values,
("rock_methods", "material_supply", "concrete_placing_method", "ancillary_counts")
(
"rock_methods",
"material_supply",
"concrete_placing_method",
"ancillary_counts",
# 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다.
"conversion_factors_override",
)
+ NULLABLE_SETTING_KEYS,
)
except Exception:
@@ -0,0 +1,202 @@
/* =============================================================================
* B08_Quantity_UI_ConversionFactors.ts
* 산출 조건 패널의 「토량환산계수(다짐)」 칸 — 고를 수 있게 열어 둔 자리.
*
* 왜 고르게 하나 (오솔길 대조 06절 3번)
* 품셈 체적변화율표가 암종마다 **범위**를 주고 「토질 시험하여 적용함을 원칙」이라 한다.
* 즉 정답 숫자가 하나가 아니다. 경쟁사(오솔길)가 전 구간 1.0 을 쓰는 것도 풍화암·연암
* 범위의 하한이라 틀린 값이 아니다. 그래서 **값을 못 박지 않고 범위를 보이며 고르게** 한다.
*
* ⚠ 기본값은 건드리지 않는다
* 정의처는 서버 `config_system_design.EARTHWORK_CONVERSION_FACTORS` 한 곳이다. 화면은
* 고른 값만 `conversion_factors_override` 로 보내고, 안 고른 갈래는 **키 자체를 안 보낸다** —
* 그래야 나중에 정본이 바뀌어도 옛 프로젝트가 따라온다.
*
* ⚠ 범위 밖을 막지 않는다
* 토질시험 값일 수 있다. 막는 대신 **사유를 적게** 하고, 그 사유가 정본에 함께 남는다.
*
* ⚠ 이 계수는 토적표만 쓰는 것이 아니다
* 유토곡선(B06) · 운반표 · 기초단가가 같은 값을 읽는다. 패널에 그 사실을 한 줄 보인다 —
* 안 보이면 「토적표만 바뀌겠지」로 읽힌다.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { ConversionFactorChoice, PumsemRange } from "./B08_Quantity_UI_EarthworkGrid";
/** locale 헬퍼 — 페이지 쪽과 같은 모양으로 둔다(문구는 `ui_template_locale_b2`). */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 화면이 들고 있는 고른 값 — `compacted` 가 `null` 이면 「안 고름」이라 저장에서 빠진다. */
export interface FactorDraft {
compacted: number | null;
reason: string;
}
/** 서버 키 → 사람이 읽는 이름. 모르는 키는 **지어내지 않고** 그대로 보인다. */
const GROUND_LABELS: Record<string, string> = {
soil: "토사",
ripping_rock: "리핑암",
blasting_rock: "발파암",
};
function groundLabel(kind: string): string {
return GROUND_LABELS[kind] ?? kind;
}
function hint(text: string): HTMLElement {
const row = document.createElement("p");
row.className = "b08-quantity__hint";
row.textContent = text;
return row;
}
/** 범위 밖인가 — 서버가 준 범위로 판정한다. 범위를 모르면 **밖이라고 하지 않는다.** */
function outOfRange(value: number, range: [number, number] | null): boolean {
if (!range) return false;
return value < range[0] || value > range[1];
}
/**
* 갈래 한 줄 — 값 칸 + 기본값·품셈 범위 안내 + (범위 밖일 때만) 사유 칸.
*
* 값을 비우면 「안 고름」으로 돌아가 기본값이 선다. 그 되돌리는 길이 있어야
* 한 번 넣은 값이 영영 남지 않는다.
*/
function factorRow(
kind: string,
choice: ConversionFactorChoice,
draft: Record<string, FactorDraft>,
onChange: () => void,
): HTMLElement {
const box = document.createElement("div");
box.className = "b08-quantity__factor";
const row = document.createElement("label");
row.className = "b08-quantity__field";
const name = document.createElement("span");
name.textContent = groundLabel(kind);
const input = document.createElement("input");
input.type = "number";
input.className = "b08-quantity__input";
input.min = "0";
input.step = "0.01";
input.placeholder = String(choice.default);
const current = draft[kind]?.compacted;
input.value = current === null || current === undefined ? "" : String(current);
row.append(name, input);
box.append(row);
const range = (choice.range ?? null) as [number, number] | null;
box.append(
hint(
`${L("B08_Quantity_Factor_Default")} ${choice.default}` +
(range
? ` · ${L("B08_Quantity_Factor_Range")} ${range[0].toFixed(2)}~${range[1].toFixed(2)}`
: ""),
),
);
// 사유 칸은 **범위 밖일 때만** 선다 — 늘 띄우면 채우지 않아도 되는 칸으로 읽힌다.
const reasonRow = document.createElement("label");
reasonRow.className = "b08-quantity__field";
const reasonName = document.createElement("span");
reasonName.textContent = L("B08_Quantity_Factor_Reason");
const reasonInput = document.createElement("input");
reasonInput.type = "text";
reasonInput.className = "b08-quantity__input";
reasonInput.value = draft[kind]?.reason ?? "";
reasonRow.append(reasonName, reasonInput);
const warning = hint(L("B08_Quantity_Factor_OutOfRange"));
warning.classList.add("b08-quantity__hint--warn");
const sync = (): void => {
const value = input.value.trim() === "" ? null : Number(input.value);
const outside = value !== null && Number.isFinite(value) && outOfRange(value, range);
warning.hidden = !outside;
reasonRow.hidden = !outside;
};
input.addEventListener("input", () => {
const raw = input.value.trim();
const value = raw === "" ? null : Number(raw);
draft[kind] = {
compacted: value !== null && Number.isFinite(value) ? value : null,
reason: draft[kind]?.reason ?? "",
};
sync();
onChange();
});
reasonInput.addEventListener("input", () => {
draft[kind] = {
compacted: draft[kind]?.compacted ?? null,
reason: reasonInput.value,
};
onChange();
});
box.append(warning, reasonRow);
sync();
return box;
}
/**
* 「토량환산계수(다짐)」 구획 전체. 서버가 준 갈래만 그린다 — 갈래 수를 화면에 안 박는다.
*
* `choices` 가 없으면(옛 응답) **아무것도 그리지 않는다** — 빈 칸을 지어내지 않는다.
*/
export function renderConversionFactorFields(
choices: Record<string, ConversionFactorChoice> | undefined,
pumsem: PumsemRange[] | undefined,
draft: Record<string, FactorDraft>,
onChange: () => void,
): HTMLElement | null {
const entries = Object.entries(choices ?? {});
if (!entries.length) return null;
const box = document.createElement("div");
box.className = "b08-quantity__factors";
const title = document.createElement("div");
title.className = "b08-quantity__field";
const titleName = document.createElement("span");
titleName.textContent = L("B08_Quantity_Side_Factors");
title.append(titleName);
box.append(title);
// 어디까지 닿는 값인지 먼저 보인다 — 토적표만 바뀌는 줄 알면 함부로 고친다.
box.append(hint(L("B08_Quantity_Factor_Reach")));
for (const [kind, choice] of entries) {
box.append(factorRow(kind, choice, draft, onChange));
}
// 품셈 암종별 범위 — 서버가 내려 준 값을 그대로 보인다(화면에 다시 적지 않는다).
if (pumsem?.length) {
box.append(
hint(
`${L("B08_Quantity_Factor_Pumsem")}` +
pumsem
.map((item) => `${item.name} ${item.min.toFixed(2)}~${item.max.toFixed(2)}`)
.join(" · "),
),
);
}
return box;
}
/** 저장 몸통에 실을 모양 — **고른 갈래만** 담는다. 빈 dict 는 「전부 기본값」이다. */
export function conversionOverridePayload(
draft: Record<string, FactorDraft>,
): Record<string, { compacted: number; reason?: string }> {
const payload: Record<string, { compacted: number; reason?: string }> = {};
for (const [kind, entry] of Object.entries(draft)) {
if (entry.compacted === null || !Number.isFinite(entry.compacted) || entry.compacted <= 0) {
continue;
}
payload[kind] = entry.reason.trim()
? { compacted: entry.compacted, reason: entry.reason.trim() }
: { compacted: entry.compacted };
}
return payload;
}
@@ -61,6 +61,8 @@ export interface SlopeTable {
/** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */
export interface QuantitySettings {
/** 고른 토량환산계수 — `{갈래: {compacted, reason?}}`. 안 고르면 키가 없다. */
conversion_factors_override?: Record<string, { compacted?: number; reason?: string }> | null;
rock_class_set?: string;
rock_classes?: string[];
rock_ratios_pct?: Record<string, number>;
@@ -95,6 +97,25 @@ export interface QuantitySettings {
wood_chipping_volume_m3?: number | null;
}
/** 갈래 하나의 「무엇을 골랐나」. 서버 `earthwork_conversion_choices` 와 짝이다. */
export interface ConversionFactorChoice {
compacted: number;
default: number;
/** 기본값과 다른 값을 골랐나. */
chosen: boolean;
/** 품셈 범위 안인가. 밖이어도 **막지 않고** 사유를 받는다. */
in_range: boolean;
range: [number, number] | null;
reason: string | null;
}
/** 품셈 암종별 체적변화율 범위 — **화면 안내용**이고 계산에 안 쓴다. */
export interface PumsemRange {
name: string;
min: number;
max: number;
}
export interface EarthworkTable {
method: string;
station_count: number;
@@ -109,6 +130,10 @@ export interface EarthworkTable {
/** 운반계획은 [저장]·[확정]에서 정본에 남는 값 — 아직 없으면 false. */
haul_available?: boolean;
settings?: QuantitySettings;
/** 갈래별 토량환산계수 선택 상태 — 산출 조건 패널이 그린다. */
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
conversion_factor_pumsem_ranges?: PumsemRange[];
}
/** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */
@@ -186,6 +186,11 @@ const CSS = `
.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
/* 칸 밑 근거 한 줄 — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */
.b08-quantity__hint { margin: 0 0 6px; font-size: 11px; line-height: 1.4; color: var(--color-text-secondary); }
/* 품셈 범위 밖을 고른 칸 — **막지 않고** 사유를 받는 자리라 경고 색만 준다. */
.b08-quantity__hint--warn { color: var(--color-warning, #b45309); }
/* 토량환산계수 구획 — 갈래마다 (값 · 안내 · 사유)가 한 덩어리로 붙는다. */
.b08-quantity__factors { margin: 4px 0 10px; }
.b08-quantity__factor { margin-bottom: 6px; }
`;
/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */
+30 -21
View File
@@ -18,6 +18,11 @@ import {
WORKFLOW_STEP_ROUTES,
} from "../A00_Common/b_workflow_nav";
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
import {
conversionOverridePayload,
renderConversionFactorFields,
type FactorDraft,
} from "./B08_Quantity_UI_ConversionFactors";
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
import {
renderHaulGrid,
@@ -98,6 +103,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
wood_chipping_volume_m3: draft.wood_chipping_volume_m3,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
// 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다.
conversion_factors_override: conversionOverridePayload(draft.conversion_factors),
}),
},
);
@@ -243,16 +250,6 @@ function selectField(
}
/** 지반 종류 표기 — 서버 키가 화면에 새지 않게. 모르는 키는 그대로 보인다. */
const GROUND_LABELS: Record<string, string> = {
soil: "토사",
ripping_rock: "리핑암",
blasting_rock: "발파암",
};
function groundLabel(kind: string): string {
return GROUND_LABELS[kind] ?? kind;
}
/** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */
export interface SupplyChoice {
supply: string;
@@ -291,6 +288,8 @@ interface DraftSettings {
wood_chipping_volume_m3: number | null;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
// 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다.
conversion_factors: Record<string, FactorDraft>;
dirty: boolean;
}
@@ -383,18 +382,18 @@ function buildQuantitySidePanel(
panel.append(devUnlockRow(projectId, reload));
}
// 계수는 서버 상수가 유일한 정의처다 — 화면은 보여 주기만 하고 값을 다시 적지 않는다.
panel.append(field(L("B08_Quantity_Side_Method"), L("B08_Quantity_Side_Method_Value")));
const entries = Object.entries(table?.conversion_factors ?? {});
if (entries.length) {
panel.append(field(L("B08_Quantity_Side_Factors"), ""));
for (const [kind, value] of entries) {
// ⚠ 서버 키(`soil`·`ripping_rock`·`blasting_rock`)를 그대로 내보내지 않는다 —
// 2026-09-08 ㉕ 화면 통과에서 좌측 세 줄이 개발자 키로 떠 있었다(`soil_guard` 와 같은 병).
// 모르는 키는 **지어내지 않고** 그대로 보인다.
panel.append(field(groundLabel(kind), String((value as { compacted: number }).compacted)));
}
}
// 토량환산계수 — **고를 수 있는 값**이다(오솔길 대조 06절 3번). 기본값 정의처는 서버 한 곳이고,
// 화면은 고른 값만 보낸다. 유토곡선·운반표·기초단가가 같이 읽는다는 안내도 그 칸이 낸다.
const factorFields = renderConversionFactorFields(
table?.conversion_factor_choices,
table?.conversion_factor_pumsem_ranges,
draft.conversion_factors,
() => {
draft.dirty = true;
},
);
if (factorFields) panel.append(factorFields);
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
const classes = [...(table?.summary?.rock_classes ?? [])];
@@ -887,6 +886,16 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
// ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다.
conversion_factors: Object.fromEntries(
Object.entries(stored.conversion_factors_override ?? {}).map(([kind, entry]) => [
kind,
{
compacted: typeof entry?.compacted === "number" ? entry.compacted : null,
reason: typeof entry?.reason === "string" ? entry.reason : "",
},
]),
),
dirty: false,
};
const reload = (): void => {
@@ -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)
+20
View File
@@ -372,6 +372,26 @@ EARTHWORK_CONVERSION_FACTORS = {
"blasting_rock": {"loose": 1.60, "compacted": 1.30},
}
# 다짐 계수 `C` 를 설계자가 고를 때 보이는 **품셈 범위**. 위 기본값이 선 근거와 같은 표다.
# ⚠ **막는 값이 아니다.** 품셈이 「토질 시험하여 적용함을 원칙」이라 하므로 범위 밖 값도
# 받되 **사유를 적게** 한다(프로젝트 설정 `conversion_factors_override` 의 `reason`).
# ⚠ 기본값을 여기서 다시 적지 않는다 — 기본값의 정의처는 위 상수 한 곳뿐이다.
EARTHWORK_CONVERSION_C_RANGES = {
"soil": (0.75, 0.90), # 풍화토 0.80~0.90 ~ 점토 0.75~0.90
"ripping_rock": (1.00, 1.30), # 풍화암 1.00~1.15 ~ 연암 1.00~1.30
"blasting_rock": (1.20, 1.40), # 보통암 1.20~1.40
}
# 품셈 체적변화율표 암종별 `C` 원문 — **화면 안내용**이다.
# 우리 3갈래와 1:1 이 아니라(풍화암·연암이 리핑암 하나로 접힌다) 계산에 쓰지 않는다.
# 경쟁사(오솔길)가 전 구간 1.0 을 쓰는 것도 풍화암·연암 범위 하한이라 범위 안이다.
EARTHWORK_CONVERSION_PUMSEM_C_RANGES = (
("풍화암", 1.00, 1.15),
("연암", 1.00, 1.30),
("보통암", 1.20, 1.40),
("경암", 1.30, 1.50),
)
# ─────────────────────────────────────────────────────────────────────────
# 5-4-5. 토공 운반장비 선정 거리 경계 (B06 유토곡선 운반계획)
@@ -0,0 +1,110 @@
"""토량환산계수를 「고를 수 있는 값」으로 연 자리 검사 (오솔길 대조 06절 3번).
못 박는 것 셋
① **기본값이 안 바뀐다** — 안 고른 프로젝트는 정본 그대로 선다.
② 고른 값은 **토적표·운반표가 같이** 읽는다(계수 정의처는 여전히 한 곳).
③ 품셈 범위 **밖도 막지 않는다** — 사유와 함께 선다.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import ( # noqa: E402
StationArea,
build_rows,
)
from B08_Quantity.B08_Quantity_Engine_HaulSummary import natural_m3 # noqa: E402
from common_util.common_util_project_settings import ( # noqa: E402
default_settings,
earthwork_conversion_choices,
earthwork_conversion_factors,
)
from config.config_system_design import ( # noqa: E402
EARTHWORK_CONVERSION_C_RANGES,
EARTHWORK_CONVERSION_FACTORS,
)
def test_안_고르면_기본값이_그대로() -> None:
"""빈 설정·기본 설정 둘 다 정본과 한 글자도 달라지지 않아야 한다."""
assert earthwork_conversion_factors({}) == EARTHWORK_CONVERSION_FACTORS
assert earthwork_conversion_factors(default_settings()["quantity"]) == (
EARTHWORK_CONVERSION_FACTORS
)
def test_고른_갈래만_갈아_끼움() -> None:
settings = {"conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}}
resolved = earthwork_conversion_factors(settings)
assert resolved["ripping_rock"]["compacted"] == pytest.approx(1.0)
# 나머지 갈래는 정본 그대로다.
assert resolved["soil"] == EARTHWORK_CONVERSION_FACTORS["soil"]
assert resolved["blasting_rock"] == EARTHWORK_CONVERSION_FACTORS["blasting_rock"]
# ⚠ 정본 dict 를 건드리지 않았는가 — 얕은 복사였다면 여기서 걸린다.
assert EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"] == pytest.approx(1.15)
def test_모르는_갈래와_말이_안_되는_값은_버림() -> None:
settings = {
"conversion_factors_override": {
"unknown_rock": {"compacted": 2.0},
"soil": {"compacted": 0},
"blasting_rock": {"compacted": "많이"},
}
}
assert earthwork_conversion_factors(settings) == EARTHWORK_CONVERSION_FACTORS
def test_토적표가_고른_계수로_섬() -> None:
"""오솔길처럼 암 계수 1.0 을 고르면 보정량이 그 값으로 선다."""
stations = [
StationArea(chainage_m=0.0),
StationArea(chainage_m=10.0, cut_rock_area_m2=2.0, cut_rock_kind="ripping_rock"),
]
factors = earthwork_conversion_factors(
{"conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}}
)
row = build_rows(stations, factors)[1]
assert row.cut_rock_volume_m3 == pytest.approx(10.0)
assert row.cut_rock_adjusted_m3 == pytest.approx(10.0) # 기본값 1.15 였다면 11.5
# 안 주면 기본값 — 같은 측점이 11.5 로 선다.
assert build_rows(stations)[1].cut_rock_adjusted_m3 == pytest.approx(11.5)
def test_운반표도_같은_계수를_씀() -> None:
"""다짐 → 자연 되돌리기(÷C)도 고른 값으로 돌아야 표끼리 안 갈린다."""
factors = earthwork_conversion_factors(
{"conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}}
)
assert natural_m3(11.5, "리핑암", factors) == pytest.approx(11.5)
assert natural_m3(11.5, "리핑암") == pytest.approx(11.5 / 1.15)
def test_범위_밖도_막지_않고_사유와_함께_섬() -> None:
low, _high = EARTHWORK_CONVERSION_C_RANGES["blasting_rock"]
settings = {
"conversion_factors_override": {
"blasting_rock": {"compacted": low - 0.5, "reason": "토질시험 값"}
}
}
resolved = earthwork_conversion_factors(settings)
assert resolved["blasting_rock"]["compacted"] == pytest.approx(low - 0.5)
choice = earthwork_conversion_choices(settings)["blasting_rock"]
assert choice["chosen"] is True
assert choice["in_range"] is False # 밖이라고 말은 하되 값은 그대로 선다.
assert choice["reason"] == "토질시험 값"
def test_선택_상태는_기본값과_범위를_함께_냄() -> None:
choice = earthwork_conversion_choices({})["ripping_rock"]
assert choice["chosen"] is False
assert choice["in_range"] is True
assert choice["default"] == pytest.approx(1.15)
assert choice["range"] == [1.00, 1.30]
+12
View File
@@ -758,6 +758,18 @@ export const ui_locales_b2 = {
B08_Quantity_Side_Method: ["산출법", "Method"],
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],
B08_Quantity_Factor_Reach: [
"이 계수는 유토곡선·운반표·기초단가에도 같이 닿습니다.",
"These factors also feed the mass-haul curve, haul table and basis units.",
],
B08_Quantity_Factor_Default: ["기본값", "Default"],
B08_Quantity_Factor_Range: ["품셈 범위", "Standard range"],
B08_Quantity_Factor_OutOfRange: [
"품셈 범위 밖입니다 — 사유를 적어 주세요(토질시험 값일 수 있습니다).",
"Outside the standard range — please note why (it may be a soil-test value).",
],
B08_Quantity_Factor_Reason: ["사유", "Reason"],
B08_Quantity_Factor_Pumsem: ["품셈 체적변화율(C)", "Standard volume-change (C)"],
/* --- B09_Estimation 원가계산 --- */
B09_Estimation_Title: ["원가계산", "Cost Estimate"],