- `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.
257 lines
12 KiB
Python
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": "설계 초기화 처리 중 오류가 발생했습니다."},
|
|
)
|