From 3a161aa18de3d977599679589e2beca878ed7612 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 16:20:17 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05):=20=EA=B2=BD=EB=A1=9C=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=C2=B7=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20(722?= =?UTF-8?q?=E2=86=92491=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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. --- B05_Profile/B05_Profile_Router.py | 235 +----------------- B05_Profile/B05_Profile_Router_Lifecycle.py | 256 ++++++++++++++++++++ main.py | 2 + 3 files changed, 260 insertions(+), 233 deletions(-) create mode 100644 B05_Profile/B05_Profile_Router_Lifecycle.py diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 4674064a..3f0a47c9 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -19,7 +19,6 @@ from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profi from B05_Profile.B05_Profile_Engine_Sections import run_section_generation from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B05_Profile.B05_Profile_Repository import ( - confirm_route, create_route, create_route_statistics, get_latest_route, @@ -28,10 +27,8 @@ from B05_Profile.B05_Profile_Repository import ( insert_route_points, update_longitudinal_grade_summary, ) -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_Router_Lifecycle import ( # noqa: F401 — 자동설계 체인이 이 경로로 부른다 + confirm_latest_route, ) from B05_Profile.B05_Profile_Schema import ( GRADE_PERCENT_FIELDS, @@ -39,8 +36,6 @@ from B05_Profile.B05_Profile_Schema import ( ContourIntervalUpdateResponse, ProfileAlignmentSaveRequest, ProfileAlignmentSaveResponse, - RouteConfirmRequest, - RouteConfirmResponse, RouteLatestResponse, RouteSolveRequest, RouteSolveResponse, @@ -61,7 +56,6 @@ from common_util.common_util_surface_confirmation import ( update_contour_interval_param, ) from common_util.common_util_workflow_state import ( - complete_stage, fail_stage, get_workflow_state, start_stage, @@ -495,228 +489,3 @@ async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONRespo status_code=500, content={"status": "error", "message": "최신 경로 조회 중 오류가 발생했습니다."}, ) - - -@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": "설계 초기화 처리 중 오류가 발생했습니다."}, - ) diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py new file mode 100644 index 00000000..1e087863 --- /dev/null +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -0,0 +1,256 @@ +"""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": "설계 초기화 처리 중 오류가 발생했습니다."}, + ) diff --git a/main.py b/main.py index b3d4cc96..af402284 100644 --- a/main.py +++ b/main.py @@ -42,6 +42,7 @@ from B04_PreProcess.B04_PreProcess_Router_Inflow import router as b04_inflow_rou from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watershed_router from B05_Profile.B05_Profile_Router import router as b05_route_router from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router +from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router from B06_Section.B06_Section_Router_Confirm import ( @@ -388,6 +389,7 @@ app.include_router(b04_inflow_router, dependencies=protected_with_company) app.include_router(b04_basins_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) +app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company)