fix(482-2 이관 막이): 옛 비정규 측점 이관이 「기타 구조물」을 만들지 않게 — 이름 빈 측점 건너뜀 · 낡은 종단이면 건너뜀 · 저장 잠금 안 · 이미 생긴 것은 알림만 · 측점 맞춤 실패 알림

- Migration: 이름 빈 측점 = 추가 측점 정본 소관
- Structures_Router: 이관 = 프로젝트 저장 잠금 안 · 종단 정본이 지금 심는 자리를 다 못 세웠으면 skipped · 응답 legacy_etc(옛 이관 「기타 구조물」)
- B05 화면: legacy_etc 가 있으면 경고 알림(지우지 않음)
- Router_Stations: 맞출 측점이 있는데 샘플링 조건 없음 = StationsUnsynced · 추가 측점 PUT 응답 stations_synced
- 새 시험 test_280_3_migrate_guard(임시 폴더) · 옛 코드에서 다섯 실패

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:51:52 +09:00
co-authored by Claude Opus 5.5
parent f27250325b
commit 2307d7d9f4
6 changed files with 266 additions and 19 deletions
+53 -11
View File
@@ -49,7 +49,7 @@ from common_util.common_util_route_follow import (
route_outside,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_station import plan_stations
from common_util.common_util_structure_station import SAME_STATION_M, plan_stations
from config.config_db import get_db_pool
from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible
@@ -266,9 +266,10 @@ class StructureMigrateRequest(BaseModel):
async def _saved_irregular_stations(
project_id: UUID, root: str, route_id: int | None
) -> list[dict[str, Any]]:
"""저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천. 화면 상세는 초안
(옮긴 관 자리)이 섞일 수 있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음)."""
) -> tuple[list[dict[str, Any]], list[float]]:
"""저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천 · 서 있는 구조물 측점
누가거리(비정규 · 이름 단 격자 · 낡음 판정 280-3). 화면 상세는 초안(옮긴 관 자리)이 섞일 수
있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음)."""
from B06_Section.B06_Section_Repository import get_longitudinal_section
from B06_Section.B06_Section_Router_Design import resolve_longitudinal_path
@@ -283,21 +284,37 @@ async def _saved_irregular_stations(
else None
)
if not row or not row.get("longitudinal_file_path"):
return []
return [], []
try:
path = resolve_longitudinal_path(Path(root), str(row["longitudinal_file_path"]))
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
except (OSError, ValueError, AttributeError):
return []
return [
return [], []
stations = [station for station in stations if station.get("chainage_m") is not None]
legacy = [
{"chainage_m": station["chainage_m"], "structure": station.get("structure") or ""}
for station in stations
if station.get("kind") == "irregular" and station.get("chainage_m") is not None
if station.get("kind") == "irregular"
]
standing = [
float(station["chainage_m"])
for station in stations
if station.get("kind") == "irregular" or station.get("structure")
]
return legacy, standing
@router.post("/{project_id}/route/structures/migrate", response_model=None)
async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse:
"""구 비정규 측점 이관 — [저장]과 같은 프로젝트 잠금 안(280-3 · 다른 탭 저장 도중 낡은 종단으로
옮기지 않음 · 몸통 `_migrate_structures`)."""
from common_util import common_util_save_guard as guard
async with guard.project_lock(str(project_id)):
return await _migrate_structures(project_id, payload)
async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest) -> JSONResponse:
"""구 비정규 측점(자유 텍스트)을 구조물 정본으로 옮긴다. 멱등 — 이미 정본에 있는
(타입, 위치)는 건너뛰고, 배관은 관 지점 정본 소관이라 옮기지 않는다
(2026-08-17 컨테이너 병합 3단계, 매핑은 `B05_Profile_Structures_Migration` 정의)."""
@@ -320,15 +337,38 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
plus_chainages(root),
)
]
legacy = await _saved_irregular_stations(project_id, root, payload.route_id)
plan = migrate_irregular_stations(legacy, planted)
legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id)
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
# 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다
# (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본).
def key_of(item: StructureInstance) -> str:
return f"{item.type_id}@{round(item.anchor_m(), 3)}"
# 옛 이관이 만든 「기타 구조물」(이름 없는 측점 · 메모 없음) — 지우지 않고 알림만(280-3)
legacy_etc = [
round(item.anchor_m(), 3)
for item in existing
if item.type_id == "etc"
and key_of(item) in migrated_before
and str(item.options.get("name") or "기타 구조물") == "기타 구조물"
and not item.memo
]
# 종단 정본이 지금 심는 자리를 다 못 세움 = 낡음(저장 도중 끊김 · 다른 탭 [저장] 도중) —
# 낡은 측점을 「기타」로 옮기지 않음(280-3)
if any(all(abs(at - s) >= SAME_STATION_M for s in standing) for at in planted):
return JSONResponse(
content={
"status": "success",
"migrated": 0,
"revision": revision,
"pipe_facilities": 0,
"skipped": "stale_longitudinal",
"legacy_etc": legacy_etc,
}
)
plan = migrate_irregular_stations(legacy, planted)
occupied = {key_of(item) for item in existing}
fresh = [
item
@@ -360,6 +400,7 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
"migrated": moved_pipes,
"revision": revision,
"pipe_facilities": moved_pipes,
"legacy_etc": legacy_etc,
}
)
new_revision = save_structures(
@@ -381,6 +422,7 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
"migrated": len(fresh) + moved_pipes,
"revision": new_revision,
"pipe_facilities": moved_pipes,
"legacy_etc": legacy_etc,
}
)
except LookupError: