"""B05 경로 확정·초기화 엔드포인트. `B05_Profile_Router` 에서 떼어낸 뒷단이다(700줄 제한, 2026-09-02). URL·응답은 그대로고 라우터 객체만 따로 두어 `main.py` 가 함께 등록한다. 확정 보조 함수는 종전대로 `B05_Profile_Router_Confirm` 에 있다. """ import asyncio import logging from pathlib import Path from uuid import UUID 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_Repository import confirm_route, get_latest_route 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 RouteConfirmRequest, RouteConfirmResponse from B06_Section.B06_Section_Repository import get_longitudinal_section from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import complete_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"]) @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-29 사용자 확정, CLAUDE.md 5장). 자동설계 체인 직후 떠 둔 `initial_snapshot/`의 DB 덤프와 정본 파일을 그대로 되돌려 놓는다 — 재계산이 아니다. 재계산으로는 초기값이 나오지 않는다: `structures.json`과 `edits/pipe_points.json`이 사용자 편집분인 채로 남아 구조물 측점이 그것에서 다시 파생되기 때문이다. 초기 설계 체인이 **실패로 끝난 프로젝트**(`initial_design.failed` 마커)는 되돌릴 기준이 없다 — 재계산으로 얼버무리지 않고 409로 관리자 문의를 안내한다. 부분 결과는 분석 안 됨과 다르지 않다(2026-09-02 사용자 확정). 마커도 스냅샷도 없는 옛 프로젝트는 종전대로 자동 설계 체인을 다시 돌린다. 어느 경로든 사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)은 전부 버려지고, 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_initial_snapshot import ( has_initial_snapshot, is_design_failed, read_design_failure, restore_initial_snapshot, restore_snapshot_files, wipe_edited_masters, ) from common_util.common_util_surface_confirmation import get_surface_confirmation_params pool = get_db_pool() try: async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) if stored_path else None restored = bool(project_root and has_initial_snapshot(project_root)) if not restored and project_root and is_design_failed(project_root): # 초기 설계가 깨진 프로젝트 — 되돌릴 기준이 없다. 재계산은 사용자가 본 초기 # 화면과 다른 값을 낳으므로 하지 않는다(2026-09-02 사용자 확정). reason = read_design_failure(project_root) or "초기 설계 처리 실패" logger.warning( "B05 초기화 거부(초기 설계 실패 프로젝트): project_id=%s reason=%s", project_id, reason, ) return JSONResponse( status_code=409, content={ "status": "error", "code": "initial_design_failed", "reason": reason, "message": ( "초기 설계가 완료되지 않아 되돌릴 초기값이 없습니다. " "관리자에게 문의해 주세요." ), }, ) async with pool.acquire() as connection: # 확정 지표면 모델을 초기 체인과 **같은 기준**으로 다시 찾는다 — 체인은 stage 1 # 확정 선택값을 쓴다(2026-08-30). config 기본값을 쓰면 그 선택과 어긋난 모델을 # 집어 재계산 결과가 초기값과 달라진다(2026-09-02 실측: `classification`/5m # 확정 프로젝트에 `csf`/1m 기본값이 걸림). try: selection = await get_surface_confirmation_params(connection, str(project_id)) surface_model_id: int | None = await find_surface_model_for_selection( connection, project_id, selection ) 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 # 복원은 같은 트랜잭션 안에서 끝낸다 — 지우기만 하고 실패하면 경로가 없다. if restored and project_root: await restore_initial_snapshot(connection, project_root, str(project_id)) await connection.commit() except Exception: await connection.rollback() raise if restored and project_root: # 정본 파일도 스냅샷본으로 되돌린다 — 이것을 빼면 구조물·관 편집분이 남아 # 초기값이 오염된다(2026-08-29). await asyncio.to_thread(restore_snapshot_files, project_root) else: # 스냅샷이 없어 재계산으로 초기값을 만드는 경로 — 편집 정본을 먼저 걷어내야 # 진짜 초기값이 나온다. 남기면 구조물 측점이 사용자 편집분에서 다시 파생된다 # (2026-08-29 실측). 체인이 끝나며 그 결과를 초기값으로 촬영한다. if project_root: removed = await asyncio.to_thread(wipe_edited_masters, project_root) if removed: logger.info( "B05 초기화: 편집 정본 제거 %s (project_id=%s)", removed, project_id ) 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": "초기값 복원에 실패했습니다." if restored else "초기 경로 재계산에 실패했습니다.", }, ) # 옛 경로의 코리도 파일은 주인이 사라졌다 — 함께 지운다(2026-08-28 백로그). try: if project_root: removed = await asyncio.to_thread( prune_corridor_files, project_root, {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, "restored": restored, } ) except Exception: logger.exception("B05 설계 초기화 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "설계 초기화 처리 중 오류가 발생했습니다."}, )