Files
Aislo/B05_Profile/B05_Profile_Structures_Router.py
T
eomsangdonandClaude Fable 5 50e5626a95 feat(B05): 구조물 타입 레지스트리·정본 CRUD 백엔드 추가
임도 구조물 전체(구조물군 B~G)를 수동 배치할 수 있도록 백엔드 기반을 만든다.
타입 정의를 코드가 아닌 데이터(JSON 레지스트리)로 두어, 구조물 목록이 바뀌어도
코드를 고치지 않고 화면 폼까지 따라오게 했다.

- B05_Profile_Structure_Types.json: 타입 레지스트리 정본 (B~G 32종 + 호환 4종).
  배치형태(point/interval/site)·옵션 스키마·마크 스타일·drawing_views 메타 포함.
- B05_Profile_Structures_Schema.py: 타입·인스턴스 Pydantic 모델.
  배치형태별 위치 필드 배타 검증, side·offset_m·placement_source·status·revision.
- B05_Profile_Structures_Repository.py: structures.json 단일 정본 읽기·쓰기.
  atomic_write_json 재사용, base_revision 불일치 시 충돌 예외,
  설계 영향 변경만 후속 단계를 무효화하도록 판정하는 지문 비교.
- B05_Profile_Structures_Migration.py: 기존 비정규 측점 이관.
  배관은 pipe_points.json 정본이라 제외, 기성막이→기슭막이, 대피로→대피소 통합,
  나머지는 이름을 살려 기타. 같은 입력이면 같은 결과.
- B05_Profile_Structures_Router.py: GET /structure-types, 구조물 목록 조회·저장.
  판번호 충돌 409, 미등록·타 정본 관리 타입 400, 저장 후 B06 이후 STALE 전파.

테스트 45건(tmp/tests) 통과. 기존 배관·배수유역·종단 편집 코드는 건드리지 않았다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:19:00 +09:00

144 lines
5.9 KiB
Python

"""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_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:
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
if not stored_path:
return None
return str(Path(resolve_stored_project_path(stored_path)))
async def _invalidate_downstream(project_id: UUID) -> None:
"""구조물이 바뀌었으니 B06(stage 3) 이후의 완료 단계를 STALE로 되돌린다.
실패해도 저장은 이미 끝났다 — 무효화를 못 했다고 저장을 되돌리면 정본과 화면이
어긋난다. 로그만 남기고 넘어간다.
"""
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()
except Exception:
logger.exception("B05 구조물 변경 후속 단계 무효화 실패: project_id=%s", project_id)
@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 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)
# 설계에 영향을 주는 변경일 때만 B06 이후를 STALE로 돌린다 — 메모만 고쳐도
# 횡단·수량을 다시 돌리게 만들지 않기 위함이다.
invalidated = requires_downstream_invalidation(previous, payload.structures)
if invalidated:
await _invalidate_downstream(project_id)
return StructureSaveResponse(
project_id=str(project_id),
revision=revision,
count=len(payload.structures),
invalidated_downstream=invalidated,
)
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": "구조물 저장 중 오류가 발생했습니다."},
)