Files
Aislo/B05_Profile/B05_Profile_Structures_Router.py
T
eomsangdonandClaude Opus 5 0e2309f5ce fix(B05): 지운 구조물 부활 차단 · 임시저장이 캐시 수정분을 저장하도록
사용자 확정 모델: 초기 계산값은 **원복용으로 그대로 두고**, 사용자가 제어한 수정
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>
2026-08-24 19:56:40 +09:00

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": "구조물 저장 중 오류가 발생했습니다."},
)