diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 24d24e20..ae4ec6d8 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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 diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py index 73454d7f..4c599456 100644 --- a/B06_Section/B06_Section_Router_HaulPlan.py +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -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: diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 22a80983..6c0913cd 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -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())) diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py b/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py index a8cfb315..c0294302 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py @@ -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), diff --git a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py index b87a3c06..3f335150 100644 --- a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py @@ -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, diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index d475296a..d3f99c10 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -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: diff --git a/B08_Quantity/B08_Quantity_UI_ConversionFactors.ts b/B08_Quantity/B08_Quantity_UI_ConversionFactors.ts new file mode 100644 index 00000000..1a2497c9 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_ConversionFactors.ts @@ -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 = { + 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, + 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 | undefined, + pumsem: PumsemRange[] | undefined, + draft: Record, + 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, +): Record { + const payload: Record = {}; + 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; +} diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 3e07e7f9..ca459800 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -61,6 +61,8 @@ export interface SlopeTable { /** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */ export interface QuantitySettings { + /** 고른 토량환산계수 — `{갈래: {compacted, reason?}}`. 안 고르면 키가 없다. */ + conversion_factors_override?: Record | null; rock_class_set?: string; rock_classes?: string[]; rock_ratios_pct?: Record; @@ -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; + /** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */ + conversion_factor_pumsem_ranges?: PumsemRange[]; } /** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */ diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts index 69be9c9f..5102228d 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -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; } `; /** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */ diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 3c5bc69c..e0bd7259 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -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 = { - 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; + // 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다. + conversion_factors: Record; 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 { ...((stored.ancillary_counts ?? {}) as Record), }, material_supply: { ...((stored.material_supply ?? {}) as Record) }, + // ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다. + 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 => { diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 28a97660..74d0a170 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -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) diff --git a/config/config_system_design.py b/config/config_system_design.py index 201716b1..02c149c9 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -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 유토곡선 운반계획) diff --git a/resources/tester/test_b08_conversion_factor_choice.py b/resources/tester/test_b08_conversion_factor_choice.py new file mode 100644 index 00000000..742ba415 --- /dev/null +++ b/resources/tester/test_b08_conversion_factor_choice.py @@ -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] diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index ce05c112..727d8a51 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],