Files
Aislo/B05_Profile/B05_Profile_Router_Lifecycle.py
T
eomsangdonandClaude Opus 5 c322f53845 feat(B05): 코리도를 전처리 체인이 미리 만들어 영구저장 + 초기값 편입
사용자 확정(2026-09-04) — 「다른 로직과 동일하게: 전처리 계산 마지막에 연산 후
영구저장, 진입 시 로딩, 조작은 캐시, 저장·확정 때 저장」. 예전에는 사용자가 B05에
처음 들어간 그 순간 브라우저가 17MB 규모를 만들어 첫 진입이 느렸음.

계산은 **재구현하지 않음** — 브라우저가 쓰는 TS 빌더(8,209줄)를 Node 로 그대로 돌림.
- `B05_Profile_UI_Corridor_Envelope.ts` 신설 — 저장 형식·버전 해시·직렬화를
  `_UI_Corridor.ts` 에서 그대로 떼어 브라우저·서버 공용으로 둠(내용 불변).
- `B05_Profile_Corridor_Node.ts` 신설 — 입력 JSON 을 받아 저장본을 내놓는 진입점.
  `npm run build:corridor` 로 번들(335kB), `npm run build` 에 물림.
- `B05_Profile_Corridor_Prebuild.py` 신설 — 상세·노선점을 모아 Node 실행 후 저장.
  번들이 TS 원본보다 낡으면 스스로 다시 만듦(두 그림이 갈라지는 것을 막는 장치).
- 체인 마지막(스냅샷 직전)에 호출. 실패는 비치명적 — 저장본이 없으면 브라우저 폴백.
- 초기값 — 스냅샷이 코리도를 `initial_corridor.json` 으로 함께 뜨고, [초기화] 복원 때
  **새 route id** 이름으로 되돌림(복원이 새 번호를 만들기 때문).
- `window.__corridorSource` 디버그 훅 — 저장본을 썼는지 다시 만들었는지 화면 밖 확인용.

자체검증: 전체 시험 381 통과·17 건너뜀(옛 테스트 2건은 이름·경로 변경에 맞춰 갱신).
실경로 — 용화 프로젝트(route 139)로 사전 생성 성공, 브라우저가 그 저장본을
`source: "stored"`, 해시 `377c2a84` 로 **그대로 채택**(재빌드 0회, 전송 278ms).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:22:27 +09:00

292 lines
14 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`이 사용자 편집분인 채로 남아 구조물 측점이 그것에서 다시
파생되기 때문이다.
초기 설계 체인이 **실패로 끝난 프로젝트**(`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": (
"초기 설계가 완료되지 않아 되돌릴 초기값이 없습니다. "
"관리자에게 문의해 주세요."
),
},
)
# 복원이 만든 새 route id — 코리도 초기값을 그 번호로 되돌리는 데 쓴다.
restored_route_id: int | None = None
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:
restored_route_id = 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). 코리도는 새 route id 이름으로 되돌아간다.
await asyncio.to_thread(restore_snapshot_files, project_root, restored_route_id)
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": "설계 초기화 처리 중 오류가 발생했습니다."},
)