Files
Aislo/B05_Profile/B05_Profile_Router_Lifecycle.py
T
eomsangdon 3a161aa18d refactor(B05): 경로 확정·초기화 엔드포인트 분리 (722→491줄)
- `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.
2026-09-02 16:20:17 +09:00

257 lines
12 KiB
Python

"""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`이 사용자 편집분인 채로 남아 구조물 측점이 그것에서 다시
파생되기 때문이다.
스냅샷이 없는 옛 프로젝트는 종전대로 자동 설계 체인을 다시 돌린다. 어느 경로든
사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)은 전부 버려지고, 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,
restore_initial_snapshot,
restore_snapshot_files,
wipe_edited_masters,
)
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
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))
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
# 복원은 같은 트랜잭션 안에서 끝낸다 — 지우기만 하고 실패하면 경로가 없다.
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": "설계 초기화 처리 중 오류가 발생했습니다."},
)