Files
Aislo/B05_Profile/B05_Profile_Route_Save.py
T
eomsangdonandClaude Opus 5.5 52553a6c6a fix(494-2 새 교차 관 기본값): 노선 바뀜 길(손 고침 저장 · 추천 적용 · 초기 종단) 새 세류선 교차 관 = B04 새 관 기본값
- followed_pipes 에서 새로 더한 관(짝 -1)만 _apply_new_defaults · 옛 빈 관 · 사용자 관 그대로
- 시험 test_494_2(옛 코드 빨강) · test_283 새 교차 관 옵션 기대 = 기본값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:19:14 +09:00

603 lines
27 KiB
Python

"""노선 저장 서버 길(346-3) — 노선 초안을 정본으로: 노선 → 풀기 → 관 · 구조물 따라감 →
종 · 횡단 한 번 → 계획고 옮김 → 횡단 이월 → B06 확정(서버 재계산 · 3D). 배수유역은 체인에서 뗌.
API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan_route`):
요청 `RouteSaveRequest` = 옛 칸(`vertices` · `initial` · `balanced_profile` · `terrain` ·
`station_snap`) +
`draft` — 계약 `RouteDraft`(`A00_Common/design/logic/design_pipeline_types.ts`) 그대로
`{rev, nodes: [[x, y]…], curveOn: [bool…], curveRadius: [m | null…], curveLock, curveArc,
stationSnap, stationSnapStep, terrain}` · 오면 `vertices` · `station_snap`(· `_step`) ·
`terrain` 대신 이것.
`as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로).
응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial,
initial_refreshed, pipes: {kept, crossing, outside}, structures: {moved, far, outside},
follow_failed, cross_dropped, drainage_stale: true, seconds}` · 추천 [적용](`as_initial`)만
`route_reset: {reset: true, dropped: {structures, pipes, plus_stations}}` 더함.
오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) ·
409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을
옛것으로 되살린 뒤).
규칙:
- 노선 초기화(`initial` = 추천 [적용] · 요구 289) = 사용자 구조물 · 추가 측점 · 관(옵션 포함)
버림 · 관 = 새 노선 세류선 교차 기본 관만 · 종단 1차 계획선 · 횡단 기본 설계 · 초기값 다시
뜸(부르는 쪽).
- 손 고침 [저장](교점 · R · 스냅 · 간격 · 요구 289 · 290) = 보이는 화면 그대로 — 관 정본을
지우지 않음 · 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
- 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴
(`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) ·
추천 [적용](`as_initial`) = 옮기지 않음 — 균형 종단(`apply_profile_target`) · 1차 계획선(438) ·
횡단 손 입력도 안 넘김(요구 277 · 측점마다 기본 설계).
- 초기값은 여기서 안 뜸 — 부르는 쪽이 초기화일 때만(자동설계 체인 직후 + 노선 초기화).
"""
from __future__ import annotations
import asyncio
import json
import logging
import shutil
import tempfile
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B05_Profile.B05_Profile_Route_Save_Parts import (
add_crossing_pipes,
carry_cross_inputs,
follow_pipes,
route_moved_stations,
seat_route_moved,
station_keys,
station_seats,
vertices_of,
)
from B05_Profile.B05_Profile_Schema import RouteReplanRequest, RouteVertexInput
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
class RouteDraftInput(BaseModel):
"""노선 초안 — 계약 `RouteDraft` 모양 그대로(칸 이름 camelCase)."""
rev: int = 0
nodes: list[tuple[float, float]] = Field(default_factory=list)
curveOn: list[bool] = Field(default_factory=list) # noqa: N815 — 계약 칸 이름
curveRadius: list[float | None] = Field(default_factory=list) # noqa: N815
curveLock: list[str | None] = Field(default_factory=list) # noqa: N815
curveArc: list[float | None] = Field(default_factory=list) # noqa: N815
stationSnap: bool = False # noqa: N815
stationSnapStep: float | None = None # noqa: N815 — 390-5
terrain: Literal["las", "sheet"] | None = None
class RouteSaveRequest(RouteReplanRequest):
"""노선 저장 요청 — 옛 [노선 반영] 칸 + 초안(`draft`) · 추천 [적용](`as_initial`)."""
draft: RouteDraftInput | None = None
as_initial: bool = False
def resolved(self) -> RouteReplanRequest:
"""초안이 오면 노드 · 곡선 · 스냅 · 지형을 옛 칸으로 편다. 칸 길이가 어긋나면 ValueError."""
if self.draft is None:
return self
draft = self.draft
count = len(draft.nodes)
for name in ("curveOn", "curveRadius"):
values = getattr(draft, name)
if values and len(values) != count:
raise ValueError(
f"노선 초안 {name} 길이({len(values)})가 노드 수({count})와 다릅니다."
)
vertices = [
RouteVertexInput(
x=x,
y=y,
curve=draft.curveOn[index] if draft.curveOn else True,
radius_m=draft.curveRadius[index] if draft.curveRadius else None,
)
for index, (x, y) in enumerate(draft.nodes)
]
return self.model_copy(
update={
"vertices": vertices,
"station_snap": draft.stationSnap,
"station_snap_step": draft.stationSnapStep,
"terrain": draft.terrain,
}
)
class _Backup:
"""체인이 고치는 파일을 손에 쥠 — 실패하면 그대로 되돌림(노선 · 관 · 구조물 · 종횡단)."""
def __init__(self, project_root: Path, paths: list[Path], folders: list[Path]) -> None:
self.held = tempfile.mkdtemp(prefix="route_save_", dir=str(project_root))
self.files = {path: path.read_bytes() if path.is_file() else None for path in paths}
self.folders: dict[Path, Path | None] = {}
for index, folder in enumerate(folders):
copy = Path(self.held) / f"dir{index}"
self.folders[folder] = shutil.copytree(folder, copy) if folder.is_dir() else None
def restore(self) -> None:
for path, saved in self.files.items():
if saved is None:
path.unlink(missing_ok=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(saved)
for folder, copy in self.folders.items():
shutil.rmtree(folder, ignore_errors=True)
if copy is not None:
shutil.copytree(copy, folder)
def drop(self) -> None:
shutil.rmtree(self.held, ignore_errors=True)
class _Fail(RuntimeError):
"""체인 한 단계 실패 — 사유를 그대로 화면까지(까닭을 덮지 않음)."""
def _reason(response: JSONResponse) -> str:
try:
return bytes(response.body).decode("utf-8", "replace")[:300]
except Exception: # noqa: BLE001 — 사유는 로그 · 응답용
return f"status={response.status_code}"
def _read_json(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]:
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
if not route_data_path:
return []
try:
return [[x, y] for x, y, _ in _load_route_polyline(project_root, route_data_path)]
except (OSError, ValueError):
return []
def followed_pipes(
old_pipes: list, old_xy: list[list[float]], points: list[dict[str, float]], streams: list
) -> tuple[list, int, list]:
"""관 따라감 + 새 세류선 교차점 기본 관(저장 안 함) → (짝, 교차 수, 새 노선 정점) — 체인 ·
추천 초기 종단(476) 한 벌."""
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
from B04_PreProcess.B04_PreProcess_Router_Basins import _apply_new_defaults
from common_util.common_util_drainage_pipes import fill_pipe_coordinates
vertices = vertices_of([(p["x"], p["y"]) for p in points])
fill_pipe_coordinates(old_pipes, vertices_of(old_xy))
pairs = follow_pipes(old_pipes, vertices)
total = float(vertices[-1].chainage_m)
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
# 새 교차 관만 B04 와 같은 등록부 기본값(54-39 · 494-2) — 옛 관 빈칸은 미확정 그대로
_apply_new_defaults([pipe for index, pipe in pairs if index < 0])
return pairs, crossing, vertices
def section_seed(request: Any, old_section_options: dict[str, Any] | None) -> dict[str, Any]:
"""체인 종횡단 생성 옵션(건너뛴 첫 생성이 남겼을 값) — 체인 · 추천 초기 종단(476) 한 벌."""
from B05_Profile.B05_Profile_Router import _section_options
seed = asdict(_section_options(request, old_section_options))
seed.pop("extra_stations", None)
return seed
async def _place_pipes(
project_id: UUID,
stored_path: str,
old_pipes: list,
old_xy: list[list[float]],
points: list[dict[str, float]],
) -> tuple[list, dict[str, int]]:
"""관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369).
초기화(492)면 부르는 쪽이 옛 관 · 옛 선을 빈 목록으로 줌 = 새 노선 교차 관만.
따라갈 선 = 구조물 · 추가 측점과 같은 `points`(사용 범위 자름 · 459) — B04 `_prepare`
노선은 범위를 안 잘라 누가거리가 갈림 · 세류선만 거기서. 좌표 없는 옛 관은 옛 노선
자리로 먼저 채움(459).
"""
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
from common_util.common_util_drainage_pipes import route_signature, save_pipe_points
from common_util.common_util_route_follow import route_outside
prepared = await _prepare(project_id)
streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"] # 도엽 없음
pairs, crossing, vertices = followed_pipes(old_pipes, old_xy, points, streams)
await asyncio.to_thread(
save_pipe_points,
stored_path,
route_signature(vertices),
[pipe for _, pipe in pairs],
vertices,
)
line = [(p["x"], p["y"]) for p in points]
outside = sum(route_outside(pipe.x, pipe.y, line) for _, pipe in pairs) # 459 · 측점 안 세움
return pairs, {"kept": len(old_pipes), "crossing": crossing, "outside": outside}
async def _carry_profile(
project_root: Path,
relative: str,
route_id: int,
old_alignment: dict[str, Any],
old_xy: list[list[float]],
new_xy: list[list[float]],
) -> None:
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
from B05_Profile.B05_Profile_Engine_Grade_Carry import (
carry_base,
carry_edits,
carry_policy,
changed_span,
)
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
from common_util.common_util_json import atomic_write_json
if not old_alignment.get("base_pvi"):
return # 옛 선형 없음 — 새 지반 첫 계획선 그대로
path = project_root / relative
longitudinal = _read_json(path)
alignment = longitudinal.get("profile_alignment") or {}
span = changed_span([tuple(p) for p in old_xy], [tuple(p) for p in new_xy])
if span is None: # 같은 노선 — 옛 계획선 통째
base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {}
else:
old_base = old_alignment["base_pvi"]
stations = [float(row["chainage_m"]) for row in longitudinal.get("stations") or []]
half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2
base = carry_base(old_xy, new_xy, old_base, alignment["base_pvi"])
edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half)
alignment["policy"] = carry_policy(old_xy, new_xy, old_alignment, alignment) # 옛 L · 열쇠(432)
alignment["base_pvi"] = base
if span is not None: # 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 초안 planline 짝)
born = route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy)
held = [float(key) for key in (edits.get("station_offsets") or {})]
alignment["base_pvi"] = seat_route_moved(alignment, born, held)
await asyncio.to_thread(atomic_write_json, path, longitudinal)
_alignment, summary, _dropped = await asyncio.to_thread(
_apply_alignment_edits, project_root, relative, edits
)
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
await update_longitudinal_grade_summary(
connection, route_id=route_id, grade_summary=summary
)
await connection.commit()
def carries_cross_inputs(initial: bool) -> bool:
"""횡단 손 입력을 새 측점(같은 자리 · 475-3)에 넘기나 — 추천 [적용]은 안 넘김(요구 277 · 새 노선
기준 초기화 · 종단 편집 · 횡단 손 입력 전부 버림) · 손 노선 [저장]만 넘김."""
return not initial
async def _carry_cross(
project_id: UUID,
project_root: Path,
route_id: int,
relative: str,
old_long: dict[str, Any],
old_designs: list[dict[str, Any]],
old_pipes: list,
old_structures: list,
pairs: list,
interval_m: float,
standard: dict[str, Any] | None,
) -> int:
"""측점 열쇠로 옛 횡단 입력을 새 측점에 얹고 전 측점을 새 계획고로 셈 · 버린 수 돌려줌."""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Design import ROUTE_CARRY_KEYS
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages
from B06_Section.B06_Section_Router_Design import (
CARRY_INPUT_KEYS,
SEAT_KEYS,
STRUCTURE_ADJUST_KEYS,
compute_default_designs,
)
new_long = _read_json(project_root / relative)
_, new_structures = load_structures(str(project_root))
old_keys = station_keys(old_long.get("stations") or [], old_pipes, old_structures, interval_m)
new_keys = station_keys(
new_long.get("stations") or [], [pipe for _, pipe in pairs], new_structures, interval_m
)
designs = {
round(float(row["chainage_m"]), 3): row["design"]
for row in old_designs
if isinstance(row.get("design"), dict)
}
# 자리 칸(암 경계 · 2단 경사 · 손 상단측)은 같은 자리 측점만(요구 275-3)
seats = (
station_seats(old_long.get("stations") or [], old_keys),
station_seats(new_long.get("stations") or [], new_keys),
)
inputs, adjusts, dropped = carry_cross_inputs(
old_keys,
new_keys,
designs,
(*CARRY_INPUT_KEYS, *ROUTE_CARRY_KEYS),
STRUCTURE_ADJUST_KEYS,
SEAT_KEYS,
seats,
)
chainages = await asyncio.to_thread(_rowless_station_chainages, project_root, relative, [])
entries = await asyncio.to_thread(
compute_default_designs, project_root, relative, chainages, standard, inputs
)
entries = [(at, {**design, **adjusts.get(round(at, 3), {})}) for at, design in entries]
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
await merge_cross_section_designs(
connection, route_id=route_id, entries=entries, replace=True, project_id=project_id
)
await connection.commit()
except Exception:
await connection.rollback()
raise
return dropped
async def _rollback(project_id: UUID, old_route_id: int, params: dict[str, Any]) -> None:
"""새 노선 행(종횡단 함께 지워짐)을 지우고 stage 2 params 를 옛것으로 — 최신 노선 = 옛 노선."""
from common_util.common_util_workflow_state import start_stage
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
async with connection.cursor() as cursor:
await cursor.execute(
"DELETE FROM routes WHERE project_id = %s AND id > %s",
(str(project_id), old_route_id),
)
await start_stage(cursor, str(project_id), 2, params)
await connection.commit()
except Exception:
await connection.rollback()
logger.exception("노선 저장 되살림(DB) 실패: project_id=%s", project_id)
async def _follow_facilities(
project_id: UUID,
project_root: Path,
old_route: dict[str, Any],
points: list[dict[str, float]],
follow_failed: list[str],
) -> dict[str, int]:
"""구조물 · 추가 측점 땅 위 자리 따라감(초기화 아닌 저장) → 구조물 요약 · 실패 = 옛 누가거리."""
from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
# 구조물 = 옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움
try:
structures = await asyncio.to_thread(
follow_structures_to_route, project_root, old_route.get("route_data_path"), points
)
except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명)
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
structures = {"moved": 0, "far": 0}
follow_failed.append("구조물")
try: # 추가 측점(368-2)
await asyncio.to_thread(
follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points]
)
except Exception: # noqa: BLE001
logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
follow_failed.append("추가 측점")
return structures
async def run_route_save_chain(
project_id: UUID,
project_root: Path,
stored_path: str,
surface_model_id: int,
selection: dict[str, Any],
old_route: dict[str, Any],
points: list[dict[str, float]],
previous_params: dict[str, Any],
profile_target: tuple[list[float], list[float]] | None = None,
planned_lines: tuple[list[list[float]], list[list[float]]] | None = None,
initial: bool = False,
) -> dict[str, Any]:
"""노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤).
`planned_lines` = (옛 · 새 계획 폴리라인) — 바뀐 구간을 화면 planline 단계와 같은 선으로 가름
(없으면 풀린 노선 geojson · 점 간격이 달라 앞머리가 일찍 갈림).
`initial` = 노선 초기화(추천 [적용]) — 사용자 시설 버림 · 옛 계획고 ·
편집 · L 안 옮김 · 새 규칙 초기 종단(476).
"""
import aiomysql
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request
from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR
from B05_Profile.B05_Profile_Plus_Stations import (
load_plus_stations,
plus_stations_path,
save_plus_stations,
)
from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route_record
from B05_Profile.B05_Profile_Structures_Repository import (
load_structures,
save_structures,
structures_file_path,
)
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_latest_section_options,
get_longitudinal_section,
)
from B06_Section.B06_Section_Router import regenerate_route_sections
from B06_Section.B06_Section_Router_Confirm import confirm_sections
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
from B06_Section.B06_Section_Schema import SectionConfirmRequest
from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file
from common_util.common_util_initial_snapshot import clear_designing, mark_designing
from common_util.common_util_workflow_state import get_workflow_state
marks = [("시작", time.perf_counter())]
old_route_id = int(old_route["id"])
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor(aiomysql.DictCursor) as cursor:
state = await get_workflow_state(cursor, str(project_id))
old_long_row = await get_longitudinal_section(connection, project_id, old_route_id)
old_designs = await get_cross_section_designs(connection, old_route_id)
old_section_options = await get_latest_section_options(connection, project_id)
stage2 = next((s for s in state.get("stages", []) if int(s.get("stage_no", -1)) == 2), None)
params = (stage2 or {}).get("params") or {}
relative_old = str((old_long_row or {}).get("longitudinal_file_path") or "")
old_long = _read_json(project_root / relative_old) if relative_old else {}
old_xy = _xy(project_root, old_route.get("route_data_path"))
old_pipes = read_pipe_points_file(pipe_points_path(stored_path))
structures_revision, old_structures = load_structures(str(project_root))
old_plus = load_plus_stations(project_root)
stage_dir = project_root / _STAGE_SUBDIR
backup = _Backup(
project_root,
[
project_root / str(old_route.get("route_data_path") or "B05_Profile/route/x"),
pipe_points_path(stored_path),
Path(structures_file_path(str(project_root))),
plus_stations_path(project_root),
],
[stage_dir / "longitudinal", stage_dir / "cross_sections"],
)
mark_designing(project_root)
try:
follow_failed: list[str] = [] # 따라감 실패 = 옛 누가거리 그대로 · 화면이 알림(459)
if initial: # 초기화(492) — 사용자 구조물 · 추가 측점 버림(실패면 `backup` 이 되살림)
if old_structures:
save_structures(str(project_root), [], base_revision=structures_revision)
if old_plus:
save_plus_stations(project_root, [], None)
structures = {"moved": 0, "far": 0, "outside": 0}
else:
structures = await _follow_facilities(
project_id, project_root, old_route, points, follow_failed
)
request = redesign_solve_request(params, selection, surface_model_id)
solved: Any = await solve_route_record(project_id, request, generate_sections=False)
if isinstance(solved, JSONResponse):
raise _Fail(f"노선 풀기 실패({solved.status_code}): {_reason(solved)}")
new_route_id = int(solved.route_id)
confirmed = await confirm_latest_route(project_id, None, mark_stage_complete=False)
if isinstance(confirmed, JSONResponse):
raise _Fail(f"노선 확정 실패({confirmed.status_code})")
new_xy = _xy(project_root, solved.route_data_path)
marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter()))
# 초기화 = 옛 관(옵션 · 사용자 관) 버림 · 새 노선 세류선 교차 관만(492)
pairs, pipes = await _place_pipes(
project_id, stored_path, [] if initial else old_pipes, [] if initial else old_xy, points
)
marks.append(("관 따라감 · 교차점", time.perf_counter()))
seed = section_seed(request, old_section_options)
made = await regenerate_route_sections(
project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed
)
if isinstance(made, JSONResponse):
raise _Fail(f"종횡단 생성 실패({made.status_code}): {_reason(made)}")
async with pool.acquire() as connection:
new_long_row = await get_longitudinal_section(connection, project_id, new_route_id)
relative = str((new_long_row or {}).get("longitudinal_file_path") or "")
marks.append(("종횡단 한 번", time.perf_counter()))
if profile_target is not None:
from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target
await apply_profile_target(project_id, new_route_id, profile_target)
elif not initial: # [적용] = 막 쓴 1차 계획선(438 · L 20) 그대로(476 · 요구 276)
await _carry_profile(
project_root,
relative,
new_route_id,
old_long.get("profile_alignment") or {},
*(planned_lines or (old_xy, new_xy)),
)
marks.append(("계획고 옮김", time.perf_counter()))
standard = stored_standard_cross_section(old_long_row)
dropped = await _carry_cross(
project_id,
project_root,
new_route_id,
relative,
old_long,
old_designs if carries_cross_inputs(initial) else [],
# 옛 관 = 옛 노선 누가거리(B05 가 읽는 자리 · 정본은 다른 선 누가거리일 수 있음 · 459)
[pipe for _, pipe in follow_pipes(old_pipes, vertices_of(old_xy))],
old_structures,
pairs,
float(seed["station_interval_m"]),
standard,
)
marks.append(("횡단 이월(측점 열쇠)", time.perf_counter()))
done = await confirm_sections(
project_id,
new_route_id,
SectionConfirmRequest(standard_cross_section=standard) if standard else None,
mark_stage_complete=False,
)
if isinstance(done, JSONResponse):
raise _Fail(f"종횡단 확정 실패({done.status_code})")
marks.append(("B06 확정 · 서버 재계산 · 3D", time.perf_counter()))
except Exception as exc: # noqa: BLE001 — 어느 단계든 옛 노선으로 되살림
if not isinstance(exc, _Fail):
logger.exception("노선 저장 체인 실패: project_id=%s", project_id)
await asyncio.to_thread(backup.restore)
await _rollback(project_id, old_route_id, previous_params)
return {"error": str(exc) if isinstance(exc, _Fail) else f"노선 저장 중 오류: {exc}"}
finally:
backup.drop()
clear_designing(project_root)
_log_steps("재확정 체인(노선 저장)", marks)
return {
"route_id": new_route_id,
"total_length_m": float(solved.total_length_m or 0.0),
"pipes": pipes,
"structures": structures,
"follow_failed": follow_failed,
"cross_dropped": dropped,
"drainage_stale": True,
"seconds": round(marks[-1][1] - marks[0][1], 2),
# 노선 초기화(추천 [적용]만 · sub1 확인 창 · 알림) — 버린 사용자 시설 수
**(
{
"route_reset": {
"reset": True,
"dropped": {
"structures": len(old_structures),
"pipes": len(old_pipes),
"plus_stations": len(old_plus),
},
}
}
if initial
else {}
),
}