"""B05 구조물 타입 레지스트리 조회·구조물 정본 CRUD 라우터. 타입 목록은 프론트가 정적으로 들고 있지 않고 여기서 받아 간다 — 레지스트리 파일 하나만 고치면 화면 폼까지 따라오게 하기 위함이다. 구조물 정본은 `B05_Profile/route/structures.json` 하나이며, 저장은 목록 전체 덮어쓰기다. 화면이 읽어간 판번호를 함께 보내고, 그 사이 다른 창이 저장했으면 409로 거절한다 — 뒤에 누른 쪽이 앞의 편집을 조용히 지우지 않게. """ 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_Repository import get_latest_route from B05_Profile.B05_Profile_Structures_Repository import ( StructureRevisionConflict, load_structures, requires_downstream_invalidation, save_structures, ) from B05_Profile.B05_Profile_Structures_Schema import ( StructureListResponse, StructureSaveRequest, StructureSaveResponse, StructureTypesResponse, load_structure_types, registry_schema_version, ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Structures"]) # B05(노선 설계)는 워크플로 2단계다 — 구조물이 바뀌면 그 뒤 단계를 다시 돌려야 한다. ROUTE_STAGE_NO = 2 _PROJECT_PATH_MISSING = { "status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다.", } @router.get("/structure-types", response_model=StructureTypesResponse) async def read_structure_types() -> StructureTypesResponse: """구조물 타입 레지스트리 정본을 그대로 돌려준다(화면 폼 생성용).""" return StructureTypesResponse( schema_version=registry_schema_version(), types=list(load_structure_types()), ) async def _project_root(project_id: UUID) -> str | None: """프로젝트 저장 경로 — 없는 프로젝트는 None (호출부가 404로 답한다). `get_project_storage_relative_path`는 없는 프로젝트에서 LookupError를 **던진다** (None 반환이 아님) — 잡지 않으면 500으로 샌다(2026-08-16 크로스체크 지적 5). """ pool = get_db_pool() try: async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) except LookupError: return None if not stored_path: return None return str(Path(resolve_stored_project_path(stored_path))) async def _route_length(project_id: UUID) -> float | None: """최신 노선 총연장(m). 노선이 없거나 조회 실패면 None — 범위 검증만 생략된다.""" try: pool = get_db_pool() async with pool.acquire() as connection: latest = await get_latest_route(connection, project_id) length = latest.get("total_length_m") if latest else None return float(length) if length else None except Exception: logger.exception("B05 노선 연장 조회 실패: project_id=%s", project_id) return None async def _invalidate_downstream(project_id: UUID) -> bool: """구조물이 바뀌었으니 B06(stage 3) 이후의 완료 단계를 STALE로 되돌린다. 실패해도 저장은 이미 끝났다 — 무효화를 못 했다고 저장을 되돌리면 정본과 화면이 어긋난다. 대신 성공 여부를 돌려줘 응답이 사실만 말하게 한다(성공한 척 금지 — 2026-08-16 크로스체크 지적 5). """ try: pool = get_db_pool() async with pool.acquire() as connection: async with connection.cursor() as cursor: await cursor.execute( """ UPDATE project_workflow_stages SET state = 'STALE' WHERE project_id = %s AND stage_no > %s AND state = 'COMPLETE' """, (str(project_id), ROUTE_STAGE_NO), ) await connection.commit() return True except Exception: logger.exception("B05 구조물 변경 후속 단계 무효화 실패: project_id=%s", project_id) return False @router.get("/{project_id}/route/structures", response_model=StructureListResponse) async def read_structures(project_id: UUID) -> StructureListResponse | JSONResponse: """배치된 구조물 목록과 현재 판번호를 반환한다.""" try: root = await _project_root(project_id) if root is None: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) revision, structures = load_structures(root) return StructureListResponse( project_id=str(project_id), revision=revision, structures=structures ) except LookupError: # 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다. return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) except Exception: logger.exception("B05 구조물 조회 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "구조물 조회 중 오류가 발생했습니다."}, ) @router.put("/{project_id}/route/structures", response_model=StructureSaveResponse) async def write_structures( project_id: UUID, payload: StructureSaveRequest ) -> StructureSaveResponse | JSONResponse: """구조물 목록을 정본에 덮어쓴다(판번호 불일치 시 409, 타입 오류 시 400).""" try: root = await _project_root(project_id) if root is None: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) _, previous = load_structures(root) revision = save_structures( root, payload.structures, base_revision=payload.base_revision, max_chainage_m=await _route_length(project_id), ) # 설계에 영향을 주는 변경일 때만 B06 이후를 STALE로 돌린다 — 메모만 고쳐도 # 횡단·수량을 다시 돌리게 만들지 않기 위함이다. 응답 플래그는 실제로 STALE # 전파가 **성공했을 때만** true(실패를 성공처럼 알리지 않는다). needs_invalidation = requires_downstream_invalidation(previous, payload.structures) invalidated = needs_invalidation and await _invalidate_downstream(project_id) return StructureSaveResponse( project_id=str(project_id), revision=revision, count=len(payload.structures), invalidated_downstream=invalidated, ) except LookupError: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) except StructureRevisionConflict as conflict: return JSONResponse( status_code=409, content={"status": "error", "message": str(conflict), "revision": conflict.actual}, ) except ValueError as error: return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) except Exception: logger.exception("B05 구조물 저장 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "구조물 저장 중 오류가 발생했습니다."}, )