fix(B05·B06): 유령 계획고 편집 근본 막음 — 옮긴 관 편집 따라감 · [저장] 때 자리 없는 편집 버림 · 알림(213-2)

- ① 관 옮김 = 편집 키도 새 자리로: B06 movePipeTo 가 resetStationEdit(옛, 새) · B05 측점 갱신이 사라진 것 하나 · 새것 하나(또는 같은 id · 0.006 m 안)를 짝으로 옮김(followedStation) — 세션 초안도 새 키라 옛 키가 다시 안 나감
- ② [저장](PUT profile-alignment)에서 서버가 측점 · 관 · 구조물 자리 어디에도 없는 편집 키를 버림(drop_unplaced_edits · 저장 차례가 관 · 구조물 뒤라 최신) · 응답 dropped_edit_chainages · 화면 알림 「계획고 편집 N곳을 지웠습니다」 · 자리 못 읽으면 안 버림
- ③ test_192_1 = 고정 사본(fixtures/b05_192_1_moved_pipe_longitudinal.json · 226fc805 460~840 m) · 새 시험 test_213_2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 15:44:28 +09:00
co-authored by Claude Opus 5.5
parent 002165f040
commit ffa7f9d236
13 changed files with 2906 additions and 34 deletions
+43 -6
View File
@@ -8,6 +8,7 @@ from typing import Any
from uuid import UUID
import aiomysql
import numpy as np
from fastapi import APIRouter
from fastapi.responses import JSONResponse
@@ -15,6 +16,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
from B05_Profile.B05_Profile_Debug import log_b05_debug
from B05_Profile.B05_Profile_Engine import run_route_design
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
from B05_Profile.B05_Profile_Engine_Grade_Alignment import drop_unplaced_edits
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
@@ -384,13 +386,30 @@ async def update_contour_interval(
)
def _structure_marks(project_root: Path, route_data_path: str) -> list[float]:
"""관 · 구조물 자리(m) — 관 지점 전부 + 구조물 측점 자리(시작 · 기준 · 종료)."""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
resolve_extra_stations,
)
pipes = _load_pipe_points(project_root, _load_route_polyline(project_root, route_data_path))
extras = resolve_extra_stations(project_root, pipes)
return [float(pipe.chainage_m) for pipe in pipes] + [float(mark) for mark, _ in extras]
def _apply_alignment_edits(
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, Any]]:
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다.
project_root: Path,
longitudinal_file_path: str,
edits: dict[str, Any],
marks: list[float] | None = None,
) -> tuple[dict[str, Any], dict[str, Any], list[float]]:
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약, 버린 자리)를 반환한다.
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다.
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. `marks`(관 · 구조물 자리)를 주면
측점 · 관 · 구조물 어디에도 없는 편집 키를 버린다(213-2 — 저장 차례가 관 · 구조물 뒤라 최신).
"""
root = project_root.resolve()
path = (root / longitudinal_file_path).resolve()
@@ -399,6 +418,16 @@ def _apply_alignment_edits(
if not path.is_file():
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
longitudinal = json.loads(path.read_text(encoding="utf-8"))
dropped: list[float] = []
if marks is not None:
stations = [float(s.get("chainage_m", 0.0)) for s in longitudinal.get("stations") or []]
edits, dropped = drop_unplaced_edits(
{
"station_offsets": dict(edits.get("station_offsets") or {}),
"curve_radii": dict(edits.get("curve_radii") or {}),
},
np.array(stations + marks, dtype=np.float64),
)
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
longitudinal["profile_alignment"] = alignment
profiles = longitudinal.get("design_profiles")
@@ -407,7 +436,7 @@ def _apply_alignment_edits(
else:
longitudinal["design_profiles"] = [profile]
atomic_write_json(path, longitudinal)
return alignment, {"id": profile["id"], **profile["summary"]}
return alignment, {"id": profile["id"], **profile["summary"]}, dropped
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
@@ -426,11 +455,18 @@ async def save_profile_alignment(
)
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
alignment, grade_summary = await asyncio.to_thread(
route = await get_latest_route(connection, project_id)
marks = None
if route and route["id"] == request.route_id and route.get("route_data_path"):
marks = await asyncio.to_thread(
_structure_marks, project_root, str(route["route_data_path"])
)
alignment, grade_summary, dropped = await asyncio.to_thread(
_apply_alignment_edits,
project_root,
str(longitudinal["longitudinal_file_path"]),
request.edits(),
marks,
)
await connection.begin()
try:
@@ -446,6 +482,7 @@ async def save_profile_alignment(
route_id=request.route_id,
profile_alignment=alignment,
grade_summary=grade_summary,
dropped_edit_chainages=dropped,
)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})