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]); },