- station_keys 가 station_key(sub2 짝)를 부름 · 관 pipe:<id>(id 없으면 at:<0.01 m>) · 구조물 structure:<id> · 같은 열쇠 둘째는 at:(B06 stationKeysOf 와 같음) - 파일 차례 관 열쇠 없앰 · 시험 test_346_3_route_save 고쳐 씀 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
497 lines
22 KiB
Python
497 lines
22 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/design_pipeline_types.ts`) 그대로
|
|
`{rev, nodes: [[x, y]…], curveOn: [bool…], curveRadius: [m | null…], curveLock, curveArc,
|
|
stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것.
|
|
`as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로).
|
|
응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial,
|
|
initial_refreshed, pipes: {kept, crossing, concave}, structures: {moved, far}, cross_dropped,
|
|
drainage_stale: true, seconds}`.
|
|
오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) ·
|
|
409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을
|
|
옛것으로 되살린 뒤).
|
|
|
|
규칙:
|
|
- 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함
|
|
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
|
|
- 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴
|
|
(`B05_Profile_Engine_Grade_Carry`) · 추천 「종단 반영」 켬이면 균형 종단
|
|
(`apply_profile_target`).
|
|
- 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]).
|
|
"""
|
|
|
|
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
|
|
|
|
import numpy as np
|
|
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,
|
|
concave_chainages,
|
|
fill_concave_pipes,
|
|
follow_pipes,
|
|
station_keys,
|
|
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
|
|
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,
|
|
"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 _ground_along(project_root: Path, selection: dict[str, Any], vertices: list) -> tuple:
|
|
"""새 노선 지반 종단(2 m) — 오목 지점 찾기. 지표면을 못 열면 빈 배열(보충 건너뜀)."""
|
|
from shapely.geometry import LineString
|
|
|
|
from B05_Profile.B05_Profile_Route_Save_Parts import CONCAVE_STEP_M
|
|
from common_util.common_util_surface_sampler import build_surface_sampler
|
|
|
|
try:
|
|
sampler = build_surface_sampler(
|
|
project_root / "B04_PreProcess" / "models",
|
|
str(selection.get("source_filter")),
|
|
str(selection.get("method") or "dtm"),
|
|
bool(selection.get("smooth")),
|
|
)
|
|
except (FileNotFoundError, KeyError, OSError, ValueError):
|
|
logger.warning("노선 저장: 지표면을 못 열어 오목 지점 보충을 건너뜀")
|
|
return np.zeros(0), np.zeros(0)
|
|
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
|
chainage = np.arange(0.0, line.length + 1e-9, CONCAVE_STEP_M)
|
|
xy = np.array([line.interpolate(float(s)).coords[0] for s in chainage], dtype=np.float64)
|
|
z, valid = sampler.sample_xy(xy)
|
|
return chainage[valid], np.asarray(z, dtype=np.float64)[valid]
|
|
|
|
|
|
async def _place_pipes(
|
|
project_id: UUID,
|
|
project_root: Path,
|
|
stored_path: str,
|
|
selection: dict[str, Any],
|
|
old_pipes: list,
|
|
old_xy: list[list[float]],
|
|
points: list[dict[str, float]],
|
|
) -> tuple[list, dict[str, int]]:
|
|
"""관 따라감 → 새 교차점 기본 관 → 오목 지점 보충 → 정본 저장(새 노선 지문)."""
|
|
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
|
|
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
|
|
from common_util.common_util_drainage_pipes import route_signature, save_pipe_points
|
|
|
|
prepared = await _prepare(project_id)
|
|
if isinstance(prepared, JSONResponse): # 도엽 · 노선 파일 없음 — 따라감만
|
|
vertices, streams = vertices_of([(p["x"], p["y"]) for p in points]), []
|
|
else:
|
|
vertices, streams = prepared["vertices"], prepared["streams"]
|
|
pairs = follow_pipes(old_pipes, vertices)
|
|
total = float(vertices[-1].chainage_m)
|
|
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
|
|
chainage, ground = await asyncio.to_thread(_ground_along, project_root, selection, vertices)
|
|
concave = fill_concave_pipes(pairs, concave_chainages(chainage, ground), vertices, old_xy)
|
|
await asyncio.to_thread(
|
|
save_pipe_points,
|
|
stored_path,
|
|
route_signature(vertices),
|
|
[pipe for _, pipe in pairs],
|
|
vertices,
|
|
)
|
|
return pairs, {"kept": len(old_pipes), "crossing": crossing, "concave": concave}
|
|
|
|
|
|
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:
|
|
"""바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 · 종단곡선 R 은 길이 차만큼 밀어 지킴."""
|
|
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_profile_edits, 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:
|
|
base, edits = carry_profile_edits(
|
|
old_alignment["base_pvi"], old_alignment.get("edits"), span, alignment["base_pvi"]
|
|
)
|
|
alignment["base_pvi"] = base
|
|
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()
|
|
|
|
|
|
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_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,
|
|
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)
|
|
}
|
|
inputs, adjusts, dropped = carry_cross_inputs(
|
|
old_keys, new_keys, designs, CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS
|
|
)
|
|
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 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,
|
|
) -> dict[str, Any]:
|
|
"""노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤)."""
|
|
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_Router import (
|
|
_section_options,
|
|
confirm_latest_route,
|
|
solve_route_record,
|
|
)
|
|
from B05_Profile.B05_Profile_Structures_Repository import load_structures, structures_file_path
|
|
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
|
|
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))
|
|
_, old_structures = load_structures(str(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))),
|
|
],
|
|
[stage_dir / "longitudinal", stage_dir / "cross_sections"],
|
|
)
|
|
mark_designing(project_root)
|
|
try:
|
|
# 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움)
|
|
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}
|
|
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()))
|
|
|
|
pairs, pipes = await _place_pipes(
|
|
project_id, project_root, stored_path, selection, old_pipes, old_xy, points
|
|
)
|
|
marks.append(("관 따라감 · 교차점 · 오목 보충", time.perf_counter()))
|
|
|
|
seed = asdict(_section_options(request, old_section_options))
|
|
seed.pop("extra_stations", None)
|
|
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)
|
|
else:
|
|
await _carry_profile(
|
|
project_root,
|
|
relative,
|
|
new_route_id,
|
|
old_long.get("profile_alignment") 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,
|
|
old_pipes,
|
|
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,
|
|
"cross_dropped": dropped,
|
|
"drainage_stale": True,
|
|
"seconds": round(marks[-1][1] - marks[0][1], 2),
|
|
}
|