자동 체인 상태 전이 분리: - confirm_latest_route/confirm_sections에 mark_stage_complete 플래그 추가 - 자동 체인(신규·재확정)은 데이터만 확정하고 stage 2·3을 IN_PROGRESS로 남김 (스텝바 기존 노란 테두리 = 계산됨·확정 전 상태로 재사용) - B06 [확정]이 stage 2+3 동시 완료 + 경로 상태 CONFIRMED 보강 후 B07 수량 이동 B05 좌측 패널 개편: - 포인트 팔레트 + 임도 기준·옵션을 경로 계산 설정 한 컨테이너로 병합, [최적 경로 계산] 버튼을 컨테이너 내부로 이동 (가끔 쓰는 무거운 재계산) - 하단 액션 행 [초기화][임시저장][횡단 이동]으로 교체, 경로 단독 확정 폐지 - [임시저장] = 관로 + 계획선 델타 + 비정규 측점·상단측 저장(mark_stage_complete=false) - [횡단 이동]/B06 [종단 이동] = 저장 없이 페이지 이동만 (B05·B06 캐시 공유 구조 유지) - [초기화] = POST /route/reset 신설: 기존 경로 삭제 후 계획노선 CSV 기본값으로 자동 체인 재실행(파일입력 직후 상태 복원), 분석용 타임아웃 적용 B06 액션 행: [종단 이동][임시저장][확정] 3버튼 구성, locale 키 정비 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
666 lines
29 KiB
Python
666 lines
29 KiB
Python
"""B05 경로 설계 FastAPI 라우터."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_Profile.B05_Profile_Debug import log_b05_debug
|
|
from B05_Profile.B05_Profile_Engine import run_route_design
|
|
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
|
|
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
|
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
|
|
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
|
from B05_Profile.B05_Profile_Repository import (
|
|
confirm_route,
|
|
create_route,
|
|
create_route_statistics,
|
|
get_latest_route,
|
|
get_route_points,
|
|
get_surface_crs_epsg,
|
|
insert_route_points,
|
|
update_longitudinal_grade_summary,
|
|
)
|
|
from B05_Profile.B05_Profile_Router_Confirm import (
|
|
_append_irregular_cross_sections,
|
|
_merge_uphill_overrides_into_longitudinal,
|
|
sync_uphill_overrides_into_designs,
|
|
)
|
|
from B05_Profile.B05_Profile_Schema import (
|
|
GRADE_PERCENT_FIELDS,
|
|
ContourIntervalUpdateRequest,
|
|
ContourIntervalUpdateResponse,
|
|
ProfileAlignmentSaveRequest,
|
|
ProfileAlignmentSaveResponse,
|
|
RouteConfirmRequest,
|
|
RouteConfirmResponse,
|
|
RouteLatestResponse,
|
|
RouteSolveRequest,
|
|
RouteSolveResponse,
|
|
normalize_grade_percent,
|
|
)
|
|
from B06_Section.B06_Section_Repository import (
|
|
create_longitudinal_section,
|
|
delete_sections_for_route,
|
|
get_latest_grade_options,
|
|
get_latest_section_options,
|
|
get_longitudinal_section,
|
|
insert_cross_sections,
|
|
)
|
|
from common_util.common_util_json import atomic_write_json
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_surface_confirmation import (
|
|
get_surface_confirmation_params,
|
|
update_contour_interval_param,
|
|
)
|
|
from common_util.common_util_workflow_state import (
|
|
complete_stage,
|
|
fail_stage,
|
|
get_workflow_state,
|
|
start_stage,
|
|
)
|
|
from config.config_db import get_db_pool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
|
|
|
|
|
def _section_options(
|
|
request: RouteSolveRequest, stored_options: dict[str, Any] | None
|
|
) -> SectionGenerationOptions:
|
|
"""요청 값 → DB 저장 옵션(단일 소스) → config 기본값 순으로 결정한다."""
|
|
defaults = SectionGenerationOptions()
|
|
stored = stored_options or {}
|
|
return SectionGenerationOptions(
|
|
station_interval_m=request.station_interval_m
|
|
or stored.get("station_interval_m")
|
|
or defaults.station_interval_m,
|
|
cross_half_width_m=request.cross_half_width_m
|
|
or stored.get("cross_half_width_m")
|
|
or defaults.cross_half_width_m,
|
|
cross_sample_interval_m=request.cross_sample_interval_m
|
|
or stored.get("cross_sample_interval_m")
|
|
or defaults.cross_sample_interval_m,
|
|
long_sample_interval_m=request.long_sample_interval_m
|
|
or stored.get("long_sample_interval_m")
|
|
or defaults.long_sample_interval_m,
|
|
include_endpoint=defaults.include_endpoint,
|
|
)
|
|
|
|
|
|
def _normalized_route_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
"""복원 응답의 경사 값을 퍼센트로 맞춘다(과거 비율 저장분 자동 환산)."""
|
|
if not params:
|
|
return params
|
|
options = params.get("options")
|
|
if not isinstance(options, dict):
|
|
return params
|
|
return {
|
|
**params,
|
|
"options": {
|
|
**options,
|
|
**{
|
|
key: normalize_grade_percent(options[key])
|
|
for key in GRADE_PERCENT_FIELDS
|
|
if key in options
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _grade_options(
|
|
request: RouteSolveRequest, stored_grade_options: dict[str, Any] | None
|
|
) -> GradeDesignOptions:
|
|
"""계획선 기준도 요청 값 → DB 저장 옵션 → config 순으로 결정한다."""
|
|
return resolve_grade_options(
|
|
request.grade_class,
|
|
terrain_type=request.terrain_type,
|
|
paved=request.paved,
|
|
main_direction=request.main_direction,
|
|
requested=request.grade_options(),
|
|
stored=stored_grade_options,
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/route/solve", response_model=RouteSolveResponse)
|
|
async def solve_route(
|
|
project_id: UUID, request: RouteSolveRequest
|
|
) -> RouteSolveResponse | JSONResponse:
|
|
"""경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
params = {
|
|
"filter_key": request.filter_key,
|
|
"method": request.method,
|
|
"smooth": request.smooth,
|
|
"points": request.points_data(),
|
|
"options": request.options(),
|
|
"algorithm": request.algorithm,
|
|
"surface_model_id": request.surface_model_id,
|
|
"station_interval_m": request.station_interval_m,
|
|
"cross_half_width_m": request.cross_half_width_m,
|
|
"cross_sample_interval_m": request.cross_sample_interval_m,
|
|
"long_sample_interval_m": request.long_sample_interval_m,
|
|
**request.grade_options(),
|
|
}
|
|
log_b05_debug(
|
|
logger,
|
|
"request.solve",
|
|
project_id=str(project_id),
|
|
workflow_stage_params=params,
|
|
)
|
|
async with pool.acquire() as connection:
|
|
async with connection.cursor() as cursor:
|
|
await start_stage(cursor, str(project_id), 2, params)
|
|
await connection.commit()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.project_workflow_stages.start_stage_committed",
|
|
project_id=str(project_id),
|
|
stage_no=2,
|
|
params=params,
|
|
)
|
|
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
|
|
# 무거운 경로 탐색은 이벤트 루프를 막지 않도록 별도 스레드에서 실행.
|
|
design = await asyncio.to_thread(
|
|
run_route_design,
|
|
project_root,
|
|
request.filter_key,
|
|
request.method,
|
|
request.smooth,
|
|
request.points_data(),
|
|
request.options(),
|
|
request.algorithm,
|
|
)
|
|
solver = design["solver_result"]
|
|
metrics = solver["metrics"]
|
|
log_b05_debug(
|
|
logger,
|
|
"calculation.complete",
|
|
project_id=str(project_id),
|
|
metrics=metrics,
|
|
required_points_ok=solver.get("required_points_ok"),
|
|
route_data_path=design["route_data_path"],
|
|
)
|
|
|
|
await connection.begin()
|
|
try:
|
|
route_record = {
|
|
"project_id": str(project_id),
|
|
"surface_model_id": request.surface_model_id,
|
|
"status": "DRAFT",
|
|
"start_chainage_m": 0.0,
|
|
"end_chainage_m": metrics.get("length_m"),
|
|
"total_length_m": metrics.get("length_m"),
|
|
"grade_percent": design["grade_percent"],
|
|
"constraints": design["constraints"],
|
|
"algorithm_params": {
|
|
**design["algorithm_params"],
|
|
"metrics": metrics,
|
|
"curve_warning_segments": solver.get("curve_warning_segments", []),
|
|
},
|
|
"route_data_path": design["route_data_path"],
|
|
}
|
|
log_b05_debug(logger, "db.routes.insert", record=route_record)
|
|
route_id = await create_route(
|
|
connection,
|
|
project_id=project_id,
|
|
surface_model_id=route_record["surface_model_id"],
|
|
total_length_m=route_record["total_length_m"],
|
|
start_chainage_m=route_record["start_chainage_m"],
|
|
end_chainage_m=route_record["end_chainage_m"],
|
|
grade_percent=route_record["grade_percent"],
|
|
constraints=route_record["constraints"],
|
|
algorithm_params=route_record["algorithm_params"],
|
|
route_data_path=route_record["route_data_path"],
|
|
)
|
|
render_points = design["render_points"]
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_points.insert_many",
|
|
route_id=route_id,
|
|
row_count=len(render_points),
|
|
first_row=render_points[0] if render_points else None,
|
|
last_row=render_points[-1] if render_points else None,
|
|
)
|
|
await insert_route_points(connection, route_id, render_points)
|
|
stats = design["statistics"]
|
|
statistics_record = {
|
|
"route_id": route_id,
|
|
"min_slope": stats["min_slope"],
|
|
"max_slope": stats["max_slope"],
|
|
"mean_slope": stats["mean_slope"],
|
|
"cost_score": stats["cost_score"],
|
|
}
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_statistics.insert",
|
|
record=statistics_record,
|
|
)
|
|
await create_route_statistics(
|
|
connection,
|
|
**statistics_record,
|
|
)
|
|
await connection.commit()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_transaction.committed",
|
|
project_id=str(project_id),
|
|
route_id=route_id,
|
|
)
|
|
except Exception as exc:
|
|
await connection.rollback()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_transaction.rolled_back",
|
|
project_id=str(project_id),
|
|
reason=str(exc),
|
|
)
|
|
raise
|
|
|
|
# 종횡단 생성 실패는 저장된 경로를 무효화하지 않으므로 비치명적으로 처리한다.
|
|
longitudinal_length_m: float | None = None
|
|
cross_section_count: int | None = None
|
|
grade_summary: dict[str, Any] | None = None
|
|
try:
|
|
crs_epsg = await get_surface_crs_epsg(
|
|
connection, project_id, request.surface_model_id
|
|
)
|
|
stored_options = await get_latest_section_options(connection, project_id)
|
|
stored_grade_options = await get_latest_grade_options(connection, project_id)
|
|
sections = await asyncio.to_thread(
|
|
run_section_generation,
|
|
project_root,
|
|
design["route_data_path"],
|
|
request.filter_key,
|
|
request.method,
|
|
request.smooth,
|
|
options=_section_options(request, stored_options),
|
|
grade_options=_grade_options(request, stored_grade_options),
|
|
# B05 폼이 계획선 입력의 authoritative 소스이므로 요청 값을 그대로
|
|
# 저장한다(사용자가 값을 지우면 저장분도 지워져 법정 기본값으로 복귀).
|
|
grade_overrides=request.grade_options(),
|
|
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
|
)
|
|
await connection.begin()
|
|
try:
|
|
await delete_sections_for_route(connection, route_id)
|
|
await create_longitudinal_section(
|
|
connection,
|
|
project_id=project_id,
|
|
route_id=route_id,
|
|
data=sections["longitudinal"]["data"],
|
|
longitudinal_file_path=sections["longitudinal"]["file_path"],
|
|
)
|
|
await insert_cross_sections(
|
|
connection,
|
|
project_id=project_id,
|
|
route_id=route_id,
|
|
sections=sections["cross_sections"],
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
longitudinal_length_m = sections["longitudinal"]["data"]["length_m"]
|
|
cross_section_count = len(sections["cross_sections"])
|
|
grade_summary = sections["longitudinal"]["data"].get("grade_summary")
|
|
except Exception:
|
|
logger.exception(
|
|
"B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s",
|
|
project_id,
|
|
route_id,
|
|
)
|
|
|
|
return RouteSolveResponse(
|
|
project_id=str(project_id),
|
|
route_id=route_id,
|
|
total_length_m=metrics.get("length_m", 0.0),
|
|
metrics=metrics,
|
|
required_points_ok=solver["required_points_ok"],
|
|
route_data_path=design["route_data_path"],
|
|
longitudinal_length_m=longitudinal_length_m,
|
|
cross_section_count=cross_section_count,
|
|
grade_summary=grade_summary,
|
|
)
|
|
except LookupError as exc:
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await fail_stage(cursor, str(project_id), 2, str(exc))
|
|
await connection.commit()
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except FileNotFoundError as exc:
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await fail_stage(cursor, str(project_id), 2, str(exc))
|
|
await connection.commit()
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except (OSError, ValueError) as exc:
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await fail_stage(cursor, str(project_id), 2, str(exc))
|
|
await connection.commit()
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception as exc:
|
|
logger.exception("B05 경로 탐색 실패: project_id=%s", project_id)
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await fail_stage(cursor, str(project_id), 2, str(exc))
|
|
await connection.commit()
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "경로 탐색 처리 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.put("/{project_id}/route/contour-interval", response_model=ContourIntervalUpdateResponse)
|
|
async def update_contour_interval(
|
|
project_id: UUID, request: ContourIntervalUpdateRequest
|
|
) -> ContourIntervalUpdateResponse | JSONResponse:
|
|
"""B05 등고선 간격 재적용 값을 stage 1 params(단일 소스)에 영속화한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
await update_contour_interval_param(
|
|
connection, str(project_id), request.contour_interval_m
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
return ContourIntervalUpdateResponse(
|
|
project_id=str(project_id), contour_interval_m=request.contour_interval_m
|
|
)
|
|
except LookupError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B05 등고선 간격 저장 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "등고선 간격 저장 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
def _apply_alignment_edits(
|
|
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any]
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다.
|
|
|
|
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
|
|
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다.
|
|
"""
|
|
root = project_root.resolve()
|
|
path = (root / longitudinal_file_path).resolve()
|
|
if root not in path.parents:
|
|
raise ValueError("종단 데이터 경로가 프로젝트 밖을 가리킵니다.")
|
|
if not path.is_file():
|
|
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
|
|
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
|
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
|
|
longitudinal["profile_alignment"] = alignment
|
|
profiles = longitudinal.get("design_profiles")
|
|
if isinstance(profiles, list) and profiles:
|
|
profiles[0] = profile
|
|
else:
|
|
longitudinal["design_profiles"] = [profile]
|
|
atomic_write_json(path, longitudinal)
|
|
return alignment, {"id": profile["id"], **profile["summary"]}
|
|
|
|
|
|
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
|
|
async def save_profile_alignment(
|
|
project_id: UUID, request: ProfileAlignmentSaveRequest
|
|
) -> ProfileAlignmentSaveResponse | JSONResponse:
|
|
"""종단 계획선 사용자 편집(측점 계획고·종단곡선)을 영속화한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
longitudinal = await get_longitudinal_section(connection, project_id, request.route_id)
|
|
if not longitudinal:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "저장된 종단 데이터가 없습니다."},
|
|
)
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
alignment, grade_summary = await asyncio.to_thread(
|
|
_apply_alignment_edits,
|
|
project_root,
|
|
str(longitudinal["longitudinal_file_path"]),
|
|
request.edits(),
|
|
)
|
|
await connection.begin()
|
|
try:
|
|
await update_longitudinal_grade_summary(
|
|
connection, route_id=request.route_id, grade_summary=grade_summary
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
return ProfileAlignmentSaveResponse(
|
|
project_id=str(project_id),
|
|
route_id=request.route_id,
|
|
profile_alignment=alignment,
|
|
grade_summary=grade_summary,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B05 계획선 편집 저장 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "계획선 편집 저장 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/route/latest", response_model=RouteLatestResponse)
|
|
async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse:
|
|
"""최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
latest = await get_latest_route(connection, project_id)
|
|
route_points = await get_route_points(connection, latest["id"]) if latest else []
|
|
surface_params = await get_surface_confirmation_params(connection, str(project_id))
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
workflow = await get_workflow_state(cursor, str(project_id))
|
|
route_stage = next(
|
|
(stage for stage in workflow["stages"] if stage["stage_no"] == 2),
|
|
None,
|
|
)
|
|
return RouteLatestResponse(
|
|
project_id=str(project_id),
|
|
route=latest,
|
|
route_points=route_points,
|
|
surface_params=surface_params,
|
|
route_params=_normalized_route_params(
|
|
route_stage.get("params") if route_stage else None
|
|
),
|
|
)
|
|
except Exception:
|
|
logger.exception("B05 최신 경로 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "최신 경로 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse)
|
|
async def confirm_latest_route(
|
|
project_id: UUID,
|
|
request: RouteConfirmRequest | None = None,
|
|
mark_stage_complete: bool = True,
|
|
) -> RouteConfirmResponse | JSONResponse:
|
|
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다.
|
|
|
|
비정규 측점(구조물)이 있으면 확정 시 해당 측점의 횡단을 생성해 종단 파일에 병합한다.
|
|
이 생성은 **비치명적**이다 — 실패해도 경로 확정(다음 단계 진행)은 그대로 진행한다.
|
|
|
|
`mark_stage_complete=False`는 자동 계산 체인용 — 데이터는 CONFIRMED로 저장하되
|
|
stage 2를 IN_PROGRESS(사용자 검토 대기, 스텝바 노란 표시)로 남긴다. stage 2 완료는
|
|
B06 종횡단 [확정]에서 stage 3과 함께 처리한다(2026-08-08 워크플로우 재정의).
|
|
"""
|
|
request = request or RouteConfirmRequest()
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
latest = await get_latest_route(connection, project_id)
|
|
if not latest:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "확정할 경로가 없습니다."},
|
|
)
|
|
if request.can_regenerate():
|
|
try:
|
|
await _append_irregular_cross_sections(connection, project_id, latest, request)
|
|
except Exception:
|
|
logger.exception(
|
|
"B05 비정규 측점 횡단 생성 실패 (경로 확정은 진행): "
|
|
"project_id=%s route_id=%s",
|
|
project_id,
|
|
latest["id"],
|
|
)
|
|
# 상단측(측구 방향) 사용자 변경분을 종단 정본에 병합한다 — 비치명적.
|
|
if request.uphill_overrides:
|
|
try:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
longitudinal = await get_longitudinal_section(
|
|
connection, project_id, latest["id"]
|
|
)
|
|
if longitudinal:
|
|
overrides = [item.model_dump() for item in request.uphill_overrides]
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
await asyncio.to_thread(
|
|
_merge_uphill_overrides_into_longitudinal,
|
|
project_root,
|
|
str(longitudinal["longitudinal_file_path"]),
|
|
overrides,
|
|
)
|
|
# 저장된 횡단 설계의 절토측·측구측도 새 방향으로 재계산 — 정본만
|
|
# 바꾸면 B06 표시·역반영이 옛 방향을 고수한다(2026-08-06 13측점).
|
|
await sync_uphill_overrides_into_designs(
|
|
connection,
|
|
project_id,
|
|
latest["id"],
|
|
project_root,
|
|
str(longitudinal["longitudinal_file_path"]),
|
|
overrides,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"B05 상단측 변경 병합 실패 (경로 확정은 진행): project_id=%s route_id=%s",
|
|
project_id,
|
|
latest["id"],
|
|
)
|
|
await connection.begin()
|
|
try:
|
|
log_b05_debug(
|
|
logger,
|
|
"db.routes.confirm",
|
|
project_id=str(project_id),
|
|
route_id=latest["id"],
|
|
previous_status=latest["status"],
|
|
next_status="CONFIRMED",
|
|
)
|
|
await confirm_route(connection, latest["id"])
|
|
if mark_stage_complete:
|
|
async with connection.cursor() as cursor:
|
|
await complete_stage(cursor, str(project_id), 2)
|
|
await connection.commit()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_confirmation.committed",
|
|
project_id=str(project_id),
|
|
route_id=latest["id"],
|
|
completed_stage=2 if mark_stage_complete else None,
|
|
)
|
|
except Exception as exc:
|
|
await connection.rollback()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_confirmation.rolled_back",
|
|
project_id=str(project_id),
|
|
route_id=latest["id"],
|
|
reason=str(exc),
|
|
)
|
|
raise
|
|
return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"])
|
|
except Exception:
|
|
logger.exception("B05 경로 확정 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "경로 확정 처리 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/route/reset")
|
|
async def reset_route_design(project_id: UUID) -> JSONResponse:
|
|
"""B05·B06 설계를 초기 자동 계산 상태로 되돌린다 ([초기화] 버튼, 2026-08-08 재정의).
|
|
|
|
사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)을 전부 버리고, 계획노선 CSV와
|
|
config 기본값으로 자동 설계 체인을 다시 돌려 파일입력 직후와 같은 상태를 만든다.
|
|
기존 경로 행을 지워야 체인의 수동 이력 보호 가드를 통과하며, 파생 데이터
|
|
(route_points·종횡단·설계 지정)는 FK CASCADE와 재계산이 정리한다. 재계산 뒤
|
|
stage 2·3은 IN_PROGRESS(검토 대기)가 된다.
|
|
"""
|
|
from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain
|
|
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
|
|
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
|
|
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
# 확정 지표면 모델을 초기 체인과 같은 기준(config 기본값)으로 다시 찾는다.
|
|
try:
|
|
surface_model_id: int | None = await find_surface_model_for_selection(
|
|
connection, project_id, surface_confirmation_defaults()
|
|
)
|
|
except Exception:
|
|
surface_model_id = None
|
|
await connection.begin()
|
|
try:
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"DELETE FROM routes WHERE project_id = %s", (str(project_id),)
|
|
)
|
|
deleted = cursor.rowcount
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
|
|
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
|
|
|
|
async with pool.acquire() as connection:
|
|
latest = await get_latest_route(connection, project_id)
|
|
if not latest:
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "초기 경로 재계산에 실패했습니다."},
|
|
)
|
|
return JSONResponse(
|
|
content={
|
|
"status": "success",
|
|
"project_id": str(project_id),
|
|
"route_id": latest["id"],
|
|
"deleted_routes": deleted,
|
|
}
|
|
)
|
|
except Exception:
|
|
logger.exception("B05 설계 초기화 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "설계 초기화 처리 중 오류가 발생했습니다."},
|
|
)
|