diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts index 7dbb41b62..651fabd86 100644 --- a/A00_Common/design/design_view_profile_sections.ts +++ b/A00_Common/design/design_view_profile_sections.ts @@ -136,6 +136,14 @@ export function createProfileSections(deps: ProfileSectionsDeps) { migratedRoutes.add(migrateKey); void migrateLegacyStations(projectId, routeId) .then((result) => { + const etc = result.legacy_etc ?? []; + if (etc.length) + showToast( + `옛 측점 이관이 만든 「기타 구조물」 ${etc.length}곳(${etc.map((m) => `${m}m`).join(" · ")}) — ` + + "측점 없는 구조물입니다. 필요 없으면 구조물 목록에서 지우세요.", + "warning", + 8000, + ); if (result.migrated > 0) { showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); return deps diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index febb3397e..5e2b4f362 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -329,7 +329,13 @@ export async function saveStructures( export async function migrateLegacyStations( projectId: string, routeId: number, -): Promise<{ status: string; migrated: number; revision: number }> { +): Promise<{ + status: string; + migrated: number; + revision: number; + /** 옛 이관이 만든 「기타 구조물」 누가거리(지우지 않음 · 알림만 · 280-3). */ + legacy_etc?: number[]; +}> { return requestJson(`/projects/${projectId}/route/structures/migrate`, { method: "POST", body: JSON.stringify({ route_id: routeId }), diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index b4703ddc2..97c433a55 100644 --- a/B05_Profile/B05_Profile_Structures_Migration.py +++ b/B05_Profile/B05_Profile_Structures_Migration.py @@ -65,7 +65,9 @@ def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bo label = str(entry.get("structure", "")).strip() at = float(entry.get("chainage_m", 0.0)) return ( - entry.get("origin") == "pipe" + # 이름 빈 측점 = 추가 측점 정본(`plus_stations.json`) 소관 — 「기타」로 안 옮김(280-3) + not label + or entry.get("origin") == "pipe" or entry.get("structureType") == PIPE_STRUCTURE_NAME or label == PIPE_STRUCTURE_NAME or label in {item.name for item in structure_type_map().values()} diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index f639d071b..020a16d9d 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -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: diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index a72d0eee1..f3da5105a 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -296,13 +296,19 @@ async def _regenerate_structure_stations( return made +class StationsUnsynced(RuntimeError): + """맞출 구조물 측점이 있는데 샘플링 조건이 없음 — 저장은 그대로 · 부른 쪽이 「맞춤 실패」 + 알림(280-3).""" + + async def ensure_structure_stations(project_id: UUID) -> int: """빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎). 곡선 측점이 지금 관 · 구조물과 어긋나도 맞춤(407-3 · [저장] 의 관 · 구조물 차례 뒤). 옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리 횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다. - 만들 수 없으면(노선 · 샘플링 조건 없음 · 빠진 것 없음) 0 — 저장을 막지 않는다. + 노선이 없거나 맞출 것이 없으면 0 · 맞출 것이 있는데 샘플링 조건이 없으면 `StationsUnsynced` + (부른 쪽이 저장은 그대로 두고 응답 `stations_synced` 거짓 · 280-3). """ paths = await _project_paths(project_id) if paths is None: @@ -326,10 +332,9 @@ async def ensure_structure_stations(project_id: UUID) -> int: return 0 snapshot = await _sampling_conditions(project_id, project_root) if snapshot is None: - logger.warning( - "B06 저장: 구조물 측점 %d곳을 못 맞춤 — 샘플링 조건 없음", len(missing) or stale + raise StationsUnsynced( + f"구조물 측점 {len(missing) or stale}곳을 못 맞춤 — 샘플링 조건 없음" ) - return 0 await _regenerate_structure_stations(project_id, project_root, route, snapshot) return len(missing) or stale @@ -447,14 +452,23 @@ async def put_plus_stations(project_id: UUID, request: PlusStationsRequest) -> J return save_plus_stations(project_root, chainages, xy) saved = await asyncio.to_thread(_save) - await ensure_structure_stations(project_id) + synced = True + try: + await ensure_structure_stations(project_id) + except StationsUnsynced: + logger.warning( + "B06 추가 측점: 측점 맞춤 실패(저장은 그대로): project_id=%s", project_id + ) + synced = False except Exception: logger.exception("B06 추가 측점 저장 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "추가 측점을 저장하지 못했습니다."}, ) - return JSONResponse(content={"status": "success", "chainages": saved}) + return JSONResponse( + content={"status": "success", "chainages": saved, "stations_synced": synced} + ) #: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게. diff --git a/resources/tester/test_280_3_migrate_guard.py b/resources/tester/test_280_3_migrate_guard.py new file mode 100644 index 000000000..9c2682d15 --- /dev/null +++ b/resources/tester/test_280_3_migrate_guard.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +"""요구 280-3 서버 — 옛 비정규 측점 이관 막이 · 이미 생긴 「기타 구조물」 알림 · 맞춤 실패 알림. + +못박는 것(임시 폴더 · 공유 storage 안 씀) + - 이름 빈 측점(추가 측점 정본 소관) = 이관 0 — 「기타 구조물」로 안 옮김. + - 종단 정본이 지금 심는 자리를 다 못 세움(저장 도중 끊김 · 다른 탭 [저장] 도중) = 이관 0. + - 정본 맞는 옛 자유 글(「대피로 2.0m」)은 종전대로 옮김. + - 옛 이관이 만든 「기타 구조물」(이관 기록 · 이름 그대로 · 메모 없음) = `legacy_etc` · 안 지움. + - 이관 = [저장]과 같은 프로젝트 잠금 안(앞 저장이 끝날 때까지 기다림). + - 맞출 측점이 있는데 샘플링 조건 없음 = `StationsUnsynced` · 추가 측점 PUT `stations_synced` 거짓. +판 = `test_452_server_keys_prune_migrate.migrate_client`. +""" + +import asyncio +import json +import sys +from pathlib import Path +from uuid import UUID + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import test_452_server_keys_prune_migrate as base # noqa: E402 + +import B06_Section.B06_Section_Router_Stations as stations # noqa: E402 +from common_util import common_util_save_guard as guard # noqa: E402 +from common_util.common_util_drainage_pipes import pipe_points_path_in # noqa: E402 + +PROJECT_ID = base.PROJECT_ID +migrate_client = base.migrate_client # 판 그대로 +router_module = base.router_module +_write_longitudinal = base._write_longitudinal + +URL = f"/api/projects/{PROJECT_ID}/route/structures/migrate" +STRUCTURES = Path("B05_Profile") / "route" / "structures.json" + + +def _pipe(project: Path, chainage: float) -> None: + path = pipe_points_path_in(project) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"route_signature": "s", "points": [{"chainage_m": chainage, "id": "p1"}]}), + encoding="utf-8", + ) + + +def _line(*irregular: tuple[float, str]) -> list[dict]: + return [ + {"chainage_m": 0.0, "kind": "bp"}, + *({"chainage_m": at, "kind": "irregular", "structure": name} for at, name in irregular), + {"chainage_m": 400.0, "kind": "ep"}, + ] + + +def test_이름_빈_측점은_이관_안_함(migrate_client): + client, project = migrate_client + _write_longitudinal(project, _line((376.0, ""))) + response = client.post(URL, json={"route_id": None}) + assert response.status_code == 200, response.text + assert response.json()["migrated"] == 0 + stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"] + assert stored == [] + + +def test_낡은_종단은_이관_안_함(migrate_client): + client, project = migrate_client + _pipe(project, 351.0) # 지금 관 = 351 · 종단은 옛 자리 330 만(저장 도중 끊김) + _write_longitudinal(project, _line((330.0, "파형강관 D800"), (600.0, "맹암거(구판)"))) + body = client.post(URL, json={"route_id": None}).json() + assert body["migrated"] == 0 and body["skipped"] == "stale_longitudinal" + assert not (project / STRUCTURES).exists() + + +def test_정본_맞는_옛_자유글은_옮김(migrate_client): + client, project = migrate_client + _pipe(project, 351.0) + _write_longitudinal(project, _line((300.0, "대피로 2.0m"), (351.0, "파형강관 D800"))) + body = client.post(URL, json={"route_id": None}).json() + assert body["migrated"] == 1 and "skipped" not in body + stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"] + assert [s["type_id"] for s in stored] == ["refuge"] + + +def test_이미_생긴_기타_구조물은_알림만(migrate_client): + client, project = migrate_client + _write_longitudinal(project, _line((376.0, ""))) + path = project / STRUCTURES + path.parent.mkdir(parents=True, exist_ok=True) + etc = { + "structure_id": "e1", + "type_id": "etc", + "placement": "point", + "chainage_m": 376.0, + "options": {"name": "기타 구조물"}, + } + named = {**etc, "structure_id": "e2", "chainage_m": 200.0, "options": {"name": "돌망태"}} + path.write_text( + json.dumps( + { + "revision": 1, + "structures": [etc, named], + "migrated_legacy": ["etc@376.0", "etc@200.0"], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + body = client.post(URL, json={"route_id": None}).json() + assert body["legacy_etc"] == [376.0] # 이름 단 것(돌망태)은 사용자 것 + stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"] + assert sorted(s["structure_id"] for s in stored) == ["e1", "e2"] # 지우지 않음 + + +def test_이관은_저장_잠금을_기다림(monkeypatch): + seen: list[str] = [] + + async def body(project_id, payload): + seen.append("migrate") + return "done" + + monkeypatch.setattr(router_module, "_migrate_structures", body) + + async def run() -> None: + lock = guard.project_lock(PROJECT_ID) + async with lock: + task = asyncio.create_task( + router_module.migrate_structures( + UUID(PROJECT_ID), router_module.StructureMigrateRequest() + ) + ) + await asyncio.sleep(0.05) + assert seen == [] # 저장 도중 = 기다림 + assert await task == "done" and seen == ["migrate"] + + asyncio.run(run()) + + +def test_샘플링_조건_없으면_맞춤_실패_알림(monkeypatch, tmp_path): + from B05_Profile import B05_Profile_Engine_Sections as sections + from B05_Profile import B05_Profile_Plus_Stations as plus + from B05_Profile import B05_Profile_Router_Confirm as confirm + + async def paths(_project_id): + return tmp_path, {"route_data_path": ""} + + async def nothing(*_args, **_kwargs): + return None + + monkeypatch.setattr(stations, "_project_paths", paths) + monkeypatch.setattr(stations, "_missing_marks", lambda *_: [120.0]) + monkeypatch.setattr(stations, "_stale_marks", lambda *_: 0) + monkeypatch.setattr(stations, "_curve_marks", lambda *_: 0) + monkeypatch.setattr(stations, "run_with_connection", nothing) + monkeypatch.setattr(stations, "_sampling_conditions", nothing) + monkeypatch.setattr(confirm, "_section_options_from_stored", lambda *_: None) + with pytest.raises(stations.StationsUnsynced): + asyncio.run(stations.ensure_structure_stations(UUID(PROJECT_ID))) + + monkeypatch.setattr(sections, "_load_route_polyline", lambda *_: []) + monkeypatch.setattr(plus, "save_plus_stations", lambda _root, chainages, _xy: list(chainages)) + response = asyncio.run( + stations.put_plus_stations(UUID(PROJECT_ID), stations.PlusStationsRequest(chainages=[120])) + ) + body = json.loads(response.body) + assert body["status"] == "success" and body["chainages"] == [120] + assert body["stations_synced"] is False # 저장은 그대로 · 맞춤 실패 알림 + + monkeypatch.setattr(stations, "_missing_marks", lambda *_: []) + assert ( + asyncio.run(stations.ensure_structure_stations(UUID(PROJECT_ID))) == 0 + ) # 맞출 것 없음 = 조용