- `B05_Profile_Router_Lifecycle.py`(256줄) 신설 — `/route/confirm` · `/route/reset` 두 엔드포인트를 자체 APIRouter 로 옮김. URL·응답 스키마 불변. - `main.py` 가 새 라우터를 함께 등록 (`b05_route_lifecycle_router`). - 자동설계 체인이 쓰던 `B05_Profile_Router.confirm_latest_route` 경로는 재수출로 유지. - 검증: 라우트 8개 그대로(openapi.json 실측), 백엔드 재기동 후 200, pytest 359 passed.
492 lines
21 KiB
Python
492 lines
21 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 (
|
|
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_Lifecycle import ( # noqa: F401 — 자동설계 체인이 이 경로로 부른다
|
|
confirm_latest_route,
|
|
)
|
|
from B05_Profile.B05_Profile_Schema import (
|
|
GRADE_PERCENT_FIELDS,
|
|
ContourIntervalUpdateRequest,
|
|
ContourIntervalUpdateResponse,
|
|
ProfileAlignmentSaveRequest,
|
|
ProfileAlignmentSaveResponse,
|
|
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 (
|
|
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,
|
|
design_speed_kph=request.design_speed_kph,
|
|
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": "최신 경로 조회 중 오류가 발생했습니다."},
|
|
)
|