Files
Aislo/B05_Profile/B05_Profile_Structures_Router.py
T
eomsangdonandClaude Opus 5 2028af6152 feat(B05): 구조물 컨테이너 병합 3단계 — 섹션 통합·측점 두 칸 입력·구 UI 폐기
사용자 화면 피드백 3건(2026-08-17) 반영 + PLAN 3단계(이관·폐기).

- 「구조물 배치」 단일 섹션: 구 비정규 측점 섹션을 흡수·삭제하고 「구조물
  추가」를 개칭. 위치 입력은 측점번호+잔여거리 두 칸(구 비정규 UI 방식),
  순서 = 시작 측점 → 기준 측점 → 종료 측점(점형은 기준만, 비우면 시작).
- A그룹 제어 통합: A군 종류 목록에 계곡 통과 시설(배관/BOX암거/물넘이/세월교)
  표시 — 추가는 관 지점 정본 경유(onPipeAdd, 시설 종류 = type_id), 목록에
  "배수유역 연동"으로 병합 표시·선택 동기화·삭제. 노출형 횡단수로·개거는
  수동 구조물.
- 그래프 측점 표현: 서클마크 툴팁·벌룬 위치를 누가거리에서 측점번호+잔여
  거리 표기로 변경(mountStructureMarks에 측점간격 주입).
- 구 비정규 측점 이관: POST /route/structures/migrate 신설 — 기존 Migration
  매핑(배관 제외·기성막이→기슭막이·대피로→대피소) 사용, 정본 기존 (타입,
  기준점) 점유 검사로 멱등. Page 진입 시 구 확정분을 자동 이관하고 건수를
  토스트로 알린다. TDD 라우터 테스트 4건.
- 구 UI 폐기: onIrregularChange/Select 콜백 제거, 배관 투영은 Page의
  pipesToStations()가 직접 생성(시설 종류별 라벨). 그래프 측점선 드래그·
  삭제는 배관 전용으로 단일화, 구 우클릭 항목은 레지스트리 타입으로 매핑.
  drainage addPipe(chainage, facility?) 확장 — 통합 목록에서 시설 종류를
  지정해 추가하면 재계산 요청에 실려 정본까지 간다.

tmp/tests 92건·ruff·typecheck·build 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:28:07 +09:00

229 lines
10 KiB
Python

"""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_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": "구조물 조회 중 오류가 발생했습니다."},
)
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)
occupied = {(item.type_id, round(item.anchor_m(), 3)) for item in existing}
fresh = [
item
for item in migrate_irregular_stations(payload.stations)
if (item.type_id, round(item.anchor_m(), 3)) not in occupied
]
if not fresh:
return JSONResponse(content={"status": "success", "migrated": 0, "revision": revision})
new_revision = save_structures(
root,
[*existing, *fresh],
base_revision=revision,
max_chainage_m=await _route_length(project_id),
)
logger.info("B05 구 비정규 측점 이관: project_id=%s, %d건", project_id, len(fresh))
return JSONResponse(
content={"status": "success", "migrated": len(fresh), "revision": new_revision}
)
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": "구조물 저장 중 오류가 발생했습니다."},
)