사용자 확정 모델: 초기 계산값은 **원복용으로 그대로 두고**, 사용자가 제어한 수정 1세트가 최종본이다. 여러 세트는 두지 않는다. 두 곳이 이 모델을 어기고 있었다. ① 지운 구조물이 되살아난다 — B05는 그려질 때마다 종단 정본의 비정규 측점을 모아 `/structures/migrate`를 부른다. 서버의 "멱등" 기준이 **지금 그 자리에 구조물이 있는가**여서, 사용자가 지우면 자리가 비고 다음 진입에서 같은 구조물이 다시 생성됐다(진행단계 오버레이로 오가면 매번). 옮긴 자리를 `structures.json`의 `migrated_legacy`에 **이력으로** 남기고, 이력에 있으면 구조물이 없어도 다시 만들지 않는다. 원천(비정규 측점)은 원복용으로 손대지 않는다. 이력은 일반 저장 경로에서도 보존한다 — 사라지면 삭제분이 부활한다. ② 임시저장이 캐시 수정분을 버린다 — B05 [임시저장]이 `cross_patches`를 보내지 않고 `invalidateSectionDetail`로 공유 캐시를 비웠다. B06에서 만져 캐시에 얹힌 구조물 조정(4축·다단·연동·표시 반폭)이 영구저장소에 못 가고 사라졌다. 계획선·비정규 측점 저장 **뒤에** 캐시 수정분을 `saveSections`로 남기고, 그 다음에 캐시를 비운다 — 순서가 뒤바뀌면 재계산이 사용자 수정을 덮는다. `saveCachedCrossPatches`·`crossPatchesFromCache`는 공유 캐시 모듈에 뒀다 — B05 페이지 파일이 이미 700줄을 넘겨 더 불리지 않기 위함이다(기존 부채). tsc/ruff/prettier 통과, pytest 200 passed(기존 실패 1건 유지). 신규 검증: tmp/tests/test_b05_structures_migration_history.py 4건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
254 lines
11 KiB
Python
254 lines
11 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_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_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)
|
|
migrated_before = load_migrated_legacy(root)
|
|
candidates = 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 candidates
|
|
if key_of(item) not in occupied and key_of(item) not in migrated_before
|
|
]
|
|
# 이번에 건너뛴 것(이미 있던 자리)도 이력에 남긴다 — 그 자리는 이관이 끝난 자리다.
|
|
history = {key_of(item) for item in candidates}
|
|
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": 0, "revision": revision})
|
|
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건", 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": "구조물 저장 중 오류가 발생했습니다."},
|
|
)
|