diff --git a/B03_FileInput/B03_FileInput_Email.py b/B03_FileInput/B03_FileInput_Email.py
index bf916af4..a4068574 100644
--- a/B03_FileInput/B03_FileInput_Email.py
+++ b/B03_FileInput/B03_FileInput_Email.py
@@ -223,8 +223,9 @@ async def send_analysis_error_email(
_summary_row("오류 내용", html.escape(error_message)),
]
)
+ safe_name = html.escape(project_name)
body = f"""
-
프로젝트 {html.escape(project_name)}의 지표면 분석 중 오류가 발생했습니다.
+ 프로젝트 {safe_name}의 지표면 분석 중 오류가 발생했습니다.
{rows}
diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py
index e3204061..0efb30c9 100644
--- a/B05_wf2_Route/B05_wf2_Route_Router.py
+++ b/B05_wf2_Route/B05_wf2_Route_Router.py
@@ -16,10 +16,7 @@ from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, resolve_grade_options
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import rebuild_alignment_profile
-from B05_wf2_Route.B05_wf2_Route_Engine_Sections import (
- generate_irregular_sections,
- run_section_generation,
-)
+from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
from B05_wf2_Route.B05_wf2_Route_Repository import (
confirm_route,
@@ -31,6 +28,10 @@ from B05_wf2_Route.B05_wf2_Route_Repository import (
insert_route_points,
update_longitudinal_grade_summary,
)
+from B05_wf2_Route.B05_wf2_Route_Router_Confirm import (
+ _append_irregular_cross_sections,
+ _merge_uphill_overrides_into_longitudinal,
+)
from B05_wf2_Route.B05_wf2_Route_Schema import (
GRADE_PERCENT_FIELDS,
ContourIntervalUpdateRequest,
@@ -113,111 +114,6 @@ def _normalized_route_params(params: dict[str, Any] | None) -> dict[str, Any] |
}
-def _section_options_from_stored(stored: dict[str, Any] | None) -> SectionGenerationOptions:
- """저장된 종횡단 옵션(단일 소스)만으로 옵션을 재구성한다(확정 시 비정규 측점 샘플링용)."""
- defaults = SectionGenerationOptions()
- stored = stored or {}
- return SectionGenerationOptions(
- station_interval_m=stored.get("station_interval_m") or defaults.station_interval_m,
- cross_half_width_m=stored.get("cross_half_width_m") or defaults.cross_half_width_m,
- cross_sample_interval_m=stored.get("cross_sample_interval_m")
- or defaults.cross_sample_interval_m,
- long_sample_interval_m=stored.get("long_sample_interval_m")
- or defaults.long_sample_interval_m,
- include_endpoint=defaults.include_endpoint,
- )
-
-
-def _merge_uphill_overrides_into_longitudinal(
- project_root: Path, longitudinal_file_path: str, overrides: list[dict[str, Any]]
-) -> None:
- """종단 정본 파일 stations의 uphill_side를 사용자 변경값으로 덮어쓴다.
-
- solve가 자동 판정한 상단측(=측구 방향)을 3D 램프 클릭으로 바꾼 경우, 확정 시점에
- 정본에 반영해 B06이 값만 읽으면 되게 한다. 사용자 지정임을 소스 필드로 남긴다.
- """
- if not overrides:
- return
- root = project_root.resolve()
- path = (root / longitudinal_file_path).resolve()
- if root not in path.parents or not path.is_file():
- return
- data = json.loads(path.read_text(encoding="utf-8"))
- stations = data.get("stations")
- if not isinstance(stations, list):
- return
- by_chainage = {round(float(item["chainage_m"]), 3): str(item["side"]) for item in overrides}
- changed = False
- for station in stations:
- side = by_chainage.get(round(float(station.get("chainage_m", -1.0)), 3))
- if side is None:
- continue
- station["uphill_side"] = side
- station["uphill_side_source"] = "user"
- changed = True
- if changed:
- data["stations"] = stations
- atomic_write_json(path, data)
-
-
-def _merge_irregular_into_longitudinal(
- project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
-) -> None:
- """종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
-
- B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
- (엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 계획선·규칙 측점·표고
- 샘플은 손대지 않는다. 파일 기반이라 재확정해도 중복이 생기지 않는다.
- """
- root = project_root.resolve()
- path = (root / longitudinal_file_path).resolve()
- if root not in path.parents or not path.is_file():
- return
- data = json.loads(path.read_text(encoding="utf-8"))
- stations = data.get("stations")
- if not isinstance(stations, list):
- return
- regular = [station for station in stations if station.get("kind") != "irregular"]
- merged = regular + list(irregular_stations)
- merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
- data["stations"] = merged
- atomic_write_json(path, data)
-
-
-async def _append_irregular_cross_sections(
- connection: aiomysql.Connection,
- project_id: UUID,
- route: dict[str, Any],
- request: RouteConfirmRequest,
-) -> None:
- """확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용)."""
- stored_path = await get_project_storage_relative_path(connection, project_id)
- project_root = Path(resolve_stored_project_path(stored_path))
- stored_options = await get_latest_section_options(connection, project_id)
- crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
- irregular_stations = await asyncio.to_thread(
- generate_irregular_sections,
- project_root,
- str(route["route_data_path"]),
- request.filter_key,
- request.method,
- request.smooth,
- extra_stations=request.extra_stations(),
- options=_section_options_from_stored(stored_options),
- crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
- )
- if not irregular_stations:
- return
- longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
- if longitudinal:
- await asyncio.to_thread(
- _merge_irregular_into_longitudinal,
- project_root,
- str(longitudinal["longitudinal_file_path"]),
- irregular_stations,
- )
-
-
def _grade_options(
request: RouteSolveRequest, stored_grade_options: dict[str, Any] | None
) -> GradeDesignOptions:
diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py b/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py
new file mode 100644
index 00000000..824ecfbe
--- /dev/null
+++ b/B05_wf2_Route/B05_wf2_Route_Router_Confirm.py
@@ -0,0 +1,130 @@
+"""B05 경로 확정 시 종단 정본 파일에 비정규 측점·상단측 변경을 병합하는 헬퍼.
+
+라우터(`B05_wf2_Route_Router.py`)의 confirm 엔드포인트가 호출한다. 파일 기반 병합이라
+재확정해도 중복이 생기지 않으며, 모든 호출은 비치명적(실패해도 경로 확정은 진행)이다.
+"""
+
+import asyncio
+import json
+from pathlib import Path
+from typing import Any
+from uuid import UUID
+
+import aiomysql
+
+from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
+from B05_wf2_Route.B05_wf2_Route_Engine_Sections import generate_irregular_sections
+from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
+from B05_wf2_Route.B05_wf2_Route_Repository import get_surface_crs_epsg
+from B05_wf2_Route.B05_wf2_Route_Schema import RouteConfirmRequest
+from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
+ get_latest_section_options,
+ get_longitudinal_section,
+)
+from common_util.common_util_json import atomic_write_json
+from common_util.common_util_storage import resolve_stored_project_path
+
+
+def _section_options_from_stored(stored: dict[str, Any] | None) -> SectionGenerationOptions:
+ """저장된 종횡단 옵션(단일 소스)만으로 옵션을 재구성한다(확정 시 비정규 측점 샘플링용)."""
+ defaults = SectionGenerationOptions()
+ stored = stored or {}
+ return SectionGenerationOptions(
+ station_interval_m=stored.get("station_interval_m") or defaults.station_interval_m,
+ cross_half_width_m=stored.get("cross_half_width_m") or defaults.cross_half_width_m,
+ cross_sample_interval_m=stored.get("cross_sample_interval_m")
+ or defaults.cross_sample_interval_m,
+ long_sample_interval_m=stored.get("long_sample_interval_m")
+ or defaults.long_sample_interval_m,
+ include_endpoint=defaults.include_endpoint,
+ )
+
+
+def _merge_uphill_overrides_into_longitudinal(
+ project_root: Path, longitudinal_file_path: str, overrides: list[dict[str, Any]]
+) -> None:
+ """종단 정본 파일 stations의 uphill_side를 사용자 변경값으로 덮어쓴다.
+
+ solve가 자동 판정한 상단측(=측구 방향)을 3D 램프 클릭으로 바꾼 경우, 확정 시점에
+ 정본에 반영해 B06이 값만 읽으면 되게 한다. 사용자 지정임을 소스 필드로 남긴다.
+ """
+ if not overrides:
+ return
+ root = project_root.resolve()
+ path = (root / longitudinal_file_path).resolve()
+ if root not in path.parents or not path.is_file():
+ return
+ data = json.loads(path.read_text(encoding="utf-8"))
+ stations = data.get("stations")
+ if not isinstance(stations, list):
+ return
+ by_chainage = {round(float(item["chainage_m"]), 3): str(item["side"]) for item in overrides}
+ changed = False
+ for station in stations:
+ side = by_chainage.get(round(float(station.get("chainage_m", -1.0)), 3))
+ if side is None:
+ continue
+ station["uphill_side"] = side
+ station["uphill_side_source"] = "user"
+ changed = True
+ if changed:
+ data["stations"] = stations
+ atomic_write_json(path, data)
+
+
+def _merge_irregular_into_longitudinal(
+ project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
+) -> None:
+ """종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
+
+ B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
+ (엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 계획선·규칙 측점·표고
+ 샘플은 손대지 않는다. 파일 기반이라 재확정해도 중복이 생기지 않는다.
+ """
+ root = project_root.resolve()
+ path = (root / longitudinal_file_path).resolve()
+ if root not in path.parents or not path.is_file():
+ return
+ data = json.loads(path.read_text(encoding="utf-8"))
+ stations = data.get("stations")
+ if not isinstance(stations, list):
+ return
+ regular = [station for station in stations if station.get("kind") != "irregular"]
+ merged = regular + list(irregular_stations)
+ merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
+ data["stations"] = merged
+ atomic_write_json(path, data)
+
+
+async def _append_irregular_cross_sections(
+ connection: aiomysql.Connection,
+ project_id: UUID,
+ route: dict[str, Any],
+ request: RouteConfirmRequest,
+) -> None:
+ """확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용)."""
+ stored_path = await get_project_storage_relative_path(connection, project_id)
+ project_root = Path(resolve_stored_project_path(stored_path))
+ stored_options = await get_latest_section_options(connection, project_id)
+ crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
+ irregular_stations = await asyncio.to_thread(
+ generate_irregular_sections,
+ project_root,
+ str(route["route_data_path"]),
+ request.filter_key,
+ request.method,
+ request.smooth,
+ extra_stations=request.extra_stations(),
+ options=_section_options_from_stored(stored_options),
+ crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
+ )
+ if not irregular_stations:
+ return
+ longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
+ if longitudinal:
+ await asyncio.to_thread(
+ _merge_irregular_into_longitudinal,
+ project_root,
+ str(longitudinal["longitudinal_file_path"]),
+ irregular_stations,
+ )
diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts
index 69cce9cd..160b4982 100644
--- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts
+++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts
@@ -509,7 +509,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise {
// TODO: [임시 테스트용] B08 강제 이동 버튼 (테스트 완료 후 제거 예정)
const tempB08Btn = createButton({
label: "[임시] B08 이동",
- variant: "outlined",
+ variant: "ghost",
onClick: () => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]);
},