Files
Aislo/B05_Profile/B05_Profile_Router.py
T
eomsangdonandClaude Opus 5 0050adf7d2 feat(B06): 독립 기슭막이 조정창 연결 + 초기화 세션·코리도 정리
백로그 3건(2026-08-28).

① 기슭막이 조정창 — 배관 세트와 같은 4축
   - RevetKey 에 "own" 추가. 조작값은 배관 벽과 같은 저장소를 쓴다
     (design.revet_adjust["own"] + 세션 b06:revetx:{project}:{route}).
   - 벽을 누르면 조정창이 열린다(활성 표시 포함). 좌우 ◀▶ · 사면 ▲▼ · 높이 ± ·
     자동 자리 초기화만 보이고 집수정·단 수·구간·연동·재질·등간격 행은 숨는다
     (재질은 구조물 옵션 형태가, 단 수는 단 수(다단)가 정본).
   - computeRevetmentLayout 이 x(좌우)·d(사면, +는 아래)·h(높이)를 반영하고
     3D도 같은 값을 읽는다(코리도 해시에 조작값 포함).

② 초기화가 프로젝트 단위 세션 값도 비운다 — b05:uphill / b06:std-cross.
   키에 route_id 가 없어 새 노선에 그대로 되붙던 값이다. 표준횡단 세션 정리는
   clearStandardCrossSession() 한 곳에서 한다.

③ 초기화가 주인 없는 코리도 파일을 지운다 — prune_corridor_files(비치명,
   삭제 개수 로그). 실측 배경: 유효 route 1개인데 19개(약 85MB)가 남아 있었다.

검증: pytest 245 passed / 7 skipped(코리도 정리 4건 신규). tsc·ruff·prettier 통과.
실측 — 315m 벽 클릭 시 조정창 제목 "기슭막이(독립)", 좌 1회·사면 아래 1회·
높이 +0.1 조작에 벽 좌표가 각각 이동하고 세션에
{"315.00:own":{"x":1,"d":1,"h":1.1}} 저장. 임시 구조물은 삭제하고 정본 복구.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 15:41:24 +09:00

687 lines
30 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,
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": "최신 경로 조회 중 오류가 발생했습니다."},
)
@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 B05_Profile.B05_Profile_Router_Corridor import prune_corridor_files
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": "초기 경로 재계산에 실패했습니다."},
)
# 옛 경로의 코리도 파일은 주인이 사라졌다 — 함께 지운다(2026-08-28 백로그).
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
if stored_path:
removed = await asyncio.to_thread(
prune_corridor_files,
Path(resolve_stored_project_path(stored_path)),
{int(latest["id"])},
)
if removed:
logger.info(
"B05 초기화: 주인 없는 코리도 파일 %d개 삭제 (project_id=%s)",
removed,
project_id,
)
except Exception: # noqa: BLE001 — 정리 실패가 초기화를 막지는 않는다
logger.exception("B05 초기화: 코리도 파일 정리 실패 (project_id=%s)", project_id)
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": "설계 초기화 처리 중 오류가 발생했습니다."},
)