증상: 파일 입력 직후 초기 계산값과 B05 [초기화] 결과가 다름. 원인 - 자동설계 체인이 5단계까지 전부 성공해야만 `save_initial_snapshot()` 호출. 중간에 깨지면 `initial_snapshot/` 미생성 → [초기화]가 복원 대신 재계산 폴백. - 재계산 폴백의 지표면 기준이 체인과 다름 — 체인은 stage 1 확정값 (`get_surface_confirmation_params`), [초기화]는 config 기본값 (`surface_confirmation_defaults`). 실측: 프로젝트 stage1 `classification`/5m 대 config `csf`/1m. - 체인이 깨져도 WF1 은 초기 분석 완료 메일을 그대로 발송. 수정 (2026-09-02 사용자 확정 — 부분 결과는 분석 안 됨과 다르지 않으므로 부분 스냅샷은 만들지 않음) - `initial_design.failed` 마커 신설 — 프로젝트 루트(스냅샷 4트리 밖), 실패 사유 기록. 체인 진입 시 옛 마커 제거, 실패 5지점 + 스냅샷 저장 실패에서 기록. - WF1 이 마커를 읽어 완료 메일 대신 `send_initial_design_failed_email()` 발송 (관리자 주소 `ADMIN_EMAIL`, 없으면 주소 없이 안내). - B05 [초기화]: 스냅샷 있으면 복원, 실패 마커 있으면 409 `initial_design_failed` 로 거부(DELETE 앞에서 반환 — 데이터 무변경), 옛 프로젝트만 종전 재계산 폴백. - 재계산 폴백의 지표면 기준을 `get_surface_confirmation_params()` 로 통일. 자체검증: `pytest tmp/tests/ -q` 366 passed / 14 skipped / 0 failed (+4). 공용 브라우저 실측 — 실패 프로젝트 [초기화] 409 응답·`routes` 행 무변경 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
288 lines
14 KiB
Python
288 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": (
|
|
"초기 설계가 완료되지 않아 되돌릴 초기값이 없습니다. "
|
|
"관리자에게 문의해 주세요."
|
|
),
|
|
},
|
|
)
|
|
|
|
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": "설계 초기화 처리 중 오류가 발생했습니다."},
|
|
)
|