"""B05 구조물 타입 레지스트리 조회·구조물 정본 CRUD 라우터. 타입 목록은 프론트가 정적으로 들고 있지 않고 여기서 받아 간다 — 레지스트리 파일 하나만 고치면 화면 폼까지 따라오게 하기 위함이다. 구조물 정본은 `B05_Profile/route/structures.json` 하나이며, 저장은 목록 전체 덮어쓰기다. 화면이 읽어간 판번호를 함께 보내고, 그 사이 다른 창이 저장했으면 409로 거절한다 — 뒤에 누른 쪽이 앞의 편집을 조용히 지우지 않게. """ import logging from pathlib import Path from typing import Any from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse from pydantic import BaseModel, Field 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_Migration import migrate_irregular_stations from B05_Profile.B05_Profile_Structures_Repository import ( StructureRevisionConflict, load_migrated_legacy, load_structures, requires_downstream_invalidation, save_structures, ) from B05_Profile.B05_Profile_Structures_Schema import ( StructureInstance, StructureListResponse, StructureSaveRequest, StructureSaveResponse, StructureTypesResponse, load_structure_types, registry_schema_version, ) from common_util.common_util_drainage_pipes import ( PipePoint, append_pipe_points_file, pipe_points_path_in, read_pipe_points_file, ) 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 def _migrate_pipe_facilities( root: str, facilities: list[PipePoint], migrated_before: set[str] ) -> tuple[int, set[str]]: """관 지점 정본이 관리하는 이관 후보를 그 정본에 덧붙인다. (옮긴 수, 이력 표식). 관 지점 파일이 없으면(= B04 배수유역 산출물 없음) 아무것도 쓰지 않고 이력도 남기지 않는다 — 원천 비정규 측점은 그대로 있으므로, 산출물이 생긴 뒤 다시 이관된다. 표식은 구조물 쪽과 같은 "타입@위치"라 한 벌로 셈해도 부딪히지 않는다. """ if not facilities: return 0, set() def key_of(point: PipePoint) -> str: return f"{point.facility}@{round(float(point.chainage_m), 3)}" path = pipe_points_path_in(Path(root)) if not path.is_file(): logger.info("B05 이관: 관 지점 정본이 없어 관 시설 %d건을 미뤘습니다.", len(facilities)) return 0, set() occupied = {key_of(point) for point in read_pipe_points_file(path)} fresh = [ point for point in facilities if key_of(point) not in occupied and key_of(point) not in migrated_before ] if fresh and append_pipe_points_file(path, fresh) is None: return 0, set() return len(fresh), {key_of(point) for point in facilities} 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": "구조물 조회 중 오류가 발생했습니다."}, ) class StructureMigrateRequest(BaseModel): """구 비정규 측점 이관 요청 — 화면이 복원한 `{chainage_m, structure}` 목록 그대로.""" stations: list[dict[str, Any]] = Field(default_factory=list) @router.post("/{project_id}/route/structures/migrate", response_model=None) async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse: """구 비정규 측점(자유 텍스트)을 구조물 정본으로 옮긴다. 멱등 — 이미 정본에 있는 (타입, 위치)는 건너뛰고, 배관은 관 지점 정본 소관이라 옮기지 않는다 (2026-08-17 컨테이너 병합 3단계, 매핑은 `B05_Profile_Structures_Migration` 정의).""" try: root = await _project_root(project_id) if root is None: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) revision, existing = load_structures(root) migrated_before = load_migrated_legacy(root) plan = migrate_irregular_stations(payload.stations) # 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, # 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다 # (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본). def key_of(item: StructureInstance) -> str: return f"{item.type_id}@{round(item.anchor_m(), 3)}" occupied = {key_of(item) for item in existing} fresh = [ item for item in plan.structures if key_of(item) not in occupied and key_of(item) not in migrated_before ] # 관 지점 정본이 관리하는 타입(기슭막이)은 구조물 목록에 넣을 수 없다 — 그쪽 # 정본에 덧붙인다(2026-09-01 사용자 확정: 건너뛰지 않고 옮긴다). moved_pipes, pipe_history = _migrate_pipe_facilities( root, plan.pipe_facilities, migrated_before ) # 이번에 건너뛴 것(이미 있던 자리)도 이력에 남긴다 — 그 자리는 이관이 끝난 자리다. history = {key_of(item) for item in plan.structures} | pipe_history if not fresh: # 새로 옮길 건 없어도 아직 이력에 없는 자리가 있으면 이력만 남긴다 — 그래야 # 다음 진입에서 그 자리가 다시 후보로 잡히지 않는다. 이력이 이미 다 있으면 # 저장하지 않는다(판번호를 괜히 올리면 다른 창의 저장이 충돌한다). if history - migrated_before: revision = save_structures( root, existing, base_revision=revision, max_chainage_m=await _route_length(project_id), migrated_legacy=history, ) return JSONResponse( content={ "status": "success", "migrated": moved_pipes, "revision": revision, "pipe_facilities": moved_pipes, } ) new_revision = save_structures( root, [*existing, *fresh], base_revision=revision, max_chainage_m=await _route_length(project_id), migrated_legacy=history, ) logger.info( "B05 구 비정규 측점 이관: project_id=%s, 구조물 %d건 · 관 시설 %d건", project_id, len(fresh), moved_pipes, ) return JSONResponse( content={ "status": "success", "migrated": len(fresh) + moved_pipes, "revision": new_revision, "pipe_facilities": moved_pipes, } ) except LookupError: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) except (StructureRevisionConflict, 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": "구조물 이관 중 오류가 발생했습니다."}, ) @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), needs_downstream_invalidation=needs_invalidation, 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": "구조물 저장 중 오류가 발생했습니다."}, )