Files
Aislo/B06_Section/B06_Section_Router_DesignSave.py
T
eomsangdonandClaude Opus 5.5 7a857d626a fix(492-1 노선 바뀜 초기화): 노선 모양이 바뀐 [저장] · 추천 [적용] = 서버 초기화 + 초기값 다시 뜸
- 판정 = 저장 · 새 계획 폴리라인 1 mm 견줌(`route_shape_changed` · TS 짝 `routeShapeChanged` = sub1) · 스냅 · 간격만 = 따라감 그대로
- 체인 초기화 = 사용자 구조물 · 추가 측점 · 관(옵션 포함) 버림 · 관 = 새 노선 세류선 교차만 · 1차 계획선 · 횡단 기본 설계 · 응답 route_reset {reset, dropped}
- 한 본문 초기화 = 보낸 관 · 구조물 · 추가 측점 · 계획고 · 관 옵션 · 넘김 칸 안 씀(페이지 설정만) · 되돌림 범위에 initial_snapshot
- 추천 초기 종단 미리 셈 = 같은 초기화 시설 · 지문에서 시설 파일 뺌
- 시험: 새 test_283_route_change_reset(옛 코드 8 실패) · 검증_sub8 자료 고정 · 실DB 시험 둘 = storage 임시 사본(helper_temp_storage) · 따라감 기대 시험 문구 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:12:35 +09:00

264 lines
13 KiB
Python

"""B05 · B06 [저장] · [확정] 한 본문 창구(PLAN 472-6 6-A) — 노선 안 바뀐 저장.
브라우저가 세션 초안 · 사용자 칸만 한 본문으로 보내면 서버가 옛 차례 그대로 안쪽 창구를 부름:
페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 관 옵션 → 상단측(비정규 측점 맞춤) → 횡단
각 안쪽 창구가 정본을 다시 셈(브라우저 결과 안 받음 · 횡단 차례 끝 서버 재계산). 하나라도 실패하면
(오류 응답 · 측점 맞춤 실패 · 재계산 실패 · 하위 차례 실패) DB 행 · storage 를 시작 전으로 되돌리고
그 차례 이름 · 까닭을 돌려줌(`common_util_save_guard` · 브레인 A). 되돌림마저 실패하면 「복구 확인
필요」. 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`waited`).
노선 모양 바뀐 저장(492 · 요구 284 · 285) = 맨 앞 노선 저장이 초기화(초기값 다시 뜸) · 뒤 차례는
페이지 설정만(보낸 시설 · 계획고 · 넘김 칸 안 씀) · 응답 `steps.route.route_reset`.
스냅 · 간격만 바뀐 노선 저장(`route` · `carry` 칸 · 6-B) = 맨 앞 노선 저장 → 관 · 구조물 초안
다시 얹기 →
위 차례(새 노선) · 횡단 = 옛 노선 세션 칸을 새 측점에 넘긴 patch · 상단측 안 보냄(요구 274) ·
늘 [저장](새 노선을 본 뒤 [확정]) · 되돌림 범위에 배수 폴더 · 지표면 확정(설계 지형 바꿈)까지
(`B06_Section_Router_DesignSave_Route`).
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
from B05_Profile.B05_Profile_Schema import (
PageSettingsRequest,
ProfileAlignmentSaveRequest,
RouteConfirmRequest,
)
from B05_Profile.B05_Profile_Structures_Schema import StructureSaveRequest
from B06_Section.B06_Section_Router_DesignSave_Route import RouteCarryRequest, StepFailed
from B06_Section.B06_Section_Router_Stations import PlusStationsRequest
from B06_Section.B06_Section_Schema import SectionConfirmRequest
from common_util import common_util_save_guard as guard
from common_util.common_util_auth import get_optional_session
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Design Save"])
class DesignSaveRequest(BaseModel):
"""한 본문 — 칸이 없으면 그 차례를 건너뜀(관은 [확정]이면 칸 없어도 다시 셈)."""
model_config = ConfigDict(extra="forbid")
route_id: int
confirm: bool = False
settings: PageSettingsRequest | None = None
#: 관 PUT 본문(`{points}` · 빈 dict = 서버 저장분 그대로)
pipes: dict[str, Any] | None = None
structures: StructureSaveRequest | None = None
plus_stations: PlusStationsRequest | None = None
alignment: ProfileAlignmentSaveRequest | None = None
#: 관 옵션 조각 {누가거리 0.01 글: {키: 값}} — 저장된 관 위에 얹어 다시 씀
culvert_options: dict[str, dict[str, Any]] | None = None
uphill: RouteConfirmRequest | None = None
cross: SectionConfirmRequest | None = None
#: 노선 초안(`/route/replan` 본문 꼴) — 오면 노선 바뀐 저장(6-B)
route: RouteSaveRequest | None = None
#: 노선 바뀐 저장의 옛 노선 세션 칸(측점 열쇠 넘김 입력)
carry: RouteCarryRequest | None = None
def _body(response: Any) -> dict[str, Any]:
"""안쪽 창구 응답 → dict · 오류 응답이면 StepFailed 로(까닭 그대로)."""
if isinstance(response, JSONResponse):
content = json.loads(bytes(response.body).decode("utf-8") or "{}")
if response.status_code >= 400 or content.get("status") == "error":
raise StepFailed("", str(content.get("message") or f"HTTP {response.status_code}"))
return content
if hasattr(response, "model_dump"):
return response.model_dump(mode="json")
return dict(response or {})
def _patched_points(stored: dict[str, Any], patches: dict[str, dict[str, Any]]) -> list | None:
"""저장된 관 목록에 관 옵션 조각 — 고친 것이 없으면 None(브라우저 `flushCulvertOptions` 짝)."""
points, touched = [], False
for point in stored.get("pipe_points") or []:
item = {key: value for key, value in point.items() if key != "lonlat"}
patch = patches.get(f"{float(point['chainage_m']):.2f}")
if patch:
touched = True
item["options"] = {**(item.get("options") or {}), **patch}
points.append(item)
return points if touched else None
async def _run_steps(
project_id: UUID, request: DesignSaveRequest, session: dict[str, Any] | None
) -> dict[str, Any]:
"""차례대로 안쪽 창구 · 차례 이름째 실패를 던짐 → 차례별 응답(알림 · 화면 맞춤용)."""
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points, put_pipe_points
from B05_Profile.B05_Profile_Router import save_profile_alignment
from B05_Profile.B05_Profile_Router_Lifecycle import confirm_latest_route, save_page_settings
from B05_Profile.B05_Profile_Structures_Router import write_structures
from B06_Section.B06_Section_Router_Confirm import confirm_sections, save_sections
from B06_Section.B06_Section_Router_Stations import put_plus_stations
out: dict[str, Any] = {}
route_id = request.route_id
moved = request.route is not None
before: dict[str, Any] = {}
async def step(name: str, call: Any) -> dict[str, Any]:
try:
out[name] = _body(await call)
except StepFailed as failed:
raise StepFailed(name, str(failed)) from None
except Exception as error:
logger.exception("한 본문 저장 %s 차례 실패: project_id=%s", name, project_id)
raise StepFailed(name, str(error) or type(error).__name__) from None
# 시험 갈래 — 화면에서 실패 · 되돌림을 돌려 볼 때만(서버를 이 환경 변수로 켬 · 평소 없음)
if os.environ.get("AISLO_DESIGN_SAVE_FAIL_AFTER") == name:
raise StepFailed(name, "시험 갈래 — 이 차례 뒤 일부러 실패")
return out[name]
if moved:
from B05_Profile.B05_Profile_Router_Replan import replan_route
from B06_Section import B06_Section_Router_DesignSave_Route as moving
before = await moving.saved_facilities(project_id)
routed = await step("route", replan_route(project_id, request.route))
route_id = int(routed["route_id"])
if (routed.get("route_reset") or {}).get("reset"):
# 노선 초기화(492) — 보낸 관 · 구조물 · 추가 측점 · 계획고 · 관 옵션 · 넘김 칸 안 씀
# (정본 = 서버 초기화 · 추천 [적용]과 한 벌) · 페이지 설정만
if request.settings is not None:
await step("settings", save_page_settings(project_id, request.settings))
return out
# 관 · 구조물 초안 = 서버가 따라 옮긴 정본 위에(Node · 옛 브라우저 `rebasePipeDraft` 짝)
try:
await moving.rebase_drafts(project_id, request, before)
except StepFailed:
raise
except Exception as error:
logger.exception("한 본문 저장 다시 얹기 실패: project_id=%s", project_id)
raise StepFailed("route", str(error) or type(error).__name__) from None
# 계획고 초안 = 새 노선에(기하 이월 · 서버가 없는 자리 버림)
if request.alignment is not None:
request.alignment = request.alignment.model_copy(update={"route_id": route_id})
if request.settings is not None:
await step("settings", save_page_settings(project_id, request.settings))
if request.pipes is not None or request.confirm:
pipes = await step(
"pipes", put_pipe_points(project_id, request.pipes or {}, session, request.confirm)
)
if pipes.get("stations_failed"):
raise StepFailed("pipes", "관 자리 측점을 못 맞췄습니다")
if request.structures is not None:
await step("structures", write_structures(project_id, request.structures, session))
if request.plus_stations is not None:
await step("plusStations", put_plus_stations(project_id, request.plus_stations))
if request.alignment is not None:
await step("alignment", save_profile_alignment(project_id, request.alignment))
if request.culvert_options:
stored = await step("culvertOptions", get_pipe_points(project_id))
points = _patched_points(stored, request.culvert_options)
if points is not None:
await step(
"culvertOptions", put_pipe_points(project_id, {"points": points}, session, False)
)
if request.uphill is not None and not moved: # 노선 바뀜 = 손 상단측 안 넘김(요구 274)
parts = await step("uphill", confirm_latest_route(project_id, request.uphill, False))
if parts.get("failed_parts"):
raise StepFailed("uphill", " · ".join(parts["failed_parts"]) + " 실패")
# 노선 바뀜 = 옛 노선 세션 칸 → 새 측점 patch(Node) · 고침이 없어도 한 번(429 ④)
if moved:
try:
carried = await moving.carry_cross(project_id, route_id, request.carry, before)
except StepFailed:
raise
except Exception as error:
logger.exception("한 본문 저장 횡단 넘김 실패: project_id=%s", project_id)
raise StepFailed("cross", str(error) or type(error).__name__) from None
out["carry"] = {"outside": int(carried.get("outside") or 0)}
body = request.cross.model_dump() if request.cross is not None else {}
body["cross_patches"] = carried.get("patches") or None
# 늘 [저장] — [확정]은 새 노선을 본 뒤 다시(옛 길 그대로)
crossing = save_sections(project_id, route_id, SectionConfirmRequest.model_validate(body))
elif request.cross is not None:
save = confirm_sections if request.confirm else save_sections
crossing = save(project_id, request.route_id, request.cross)
else:
return out
cross = await step("cross", crossing)
if cross.get("recomputed") is False:
raise StepFailed("cross", "서버 재계산 실패")
if cross.get("stations_synced") is False:
raise StepFailed("cross", "구조물 측점 맞춤 실패")
return out
@router.post("/{project_id}/design/save")
async def save_design(
project_id: UUID,
request: DesignSaveRequest,
session: dict[str, Any] | None = Depends(get_optional_session),
) -> JSONResponse:
"""[저장] · [확정] 한 본문 — 전부 저장하거나 아무것도 안 바꿈."""
lock = guard.project_lock(str(project_id))
waited = lock.locked()
async with lock:
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
moved = request.route is not None # 노선 저장 = 설계 지형 바꿈(지표면 확정)도 떠 둠
rows = await guard.snapshot_rows(connection, str(project_id), surface=moved)
root = Path(resolve_stored_project_path(stored_path))
pipes = request.pipes is not None or bool(request.culvert_options) or request.confirm
pipes = pipes or moved # 노선 저장 = 관 따라감 · 새 교차 관(배수 폴더)
scope = guard.STORAGE_SCOPE + (guard.PIPE_SCOPE if pipes else ())
# 노선 저장 = 초기화면 초기값 사본도 다시 뜸(492)
files = guard.snapshot_storage(root, scope + (guard.INITIAL_SCOPE if moved else ()))
try:
steps = await _run_steps(project_id, request, session)
except StepFailed as failed:
restored = await _restore(project_id, rows, files)
return JSONResponse(
status_code=409 if restored else 500,
content={
"status": "error",
"step": failed.step,
"message": str(failed)
if restored
else f"{failed} — 저장 실패 · 복구 확인 필요",
"restored": restored,
"waited": waited,
},
)
finally:
files.discard()
return JSONResponse(content={"status": "success", "waited": waited, "steps": steps})
async def _restore(project_id: UUID, rows: dict[str, list], files: guard.StorageSnapshot) -> bool:
"""떠 둔 대로 — 성공 = 참 · 하나라도 못 되돌리면 로그 남기고 거짓(조용히 넘기지 않음)."""
ok = True
try:
files.restore()
except Exception:
logger.exception("한 본문 저장 되돌림 실패(storage): project_id=%s", project_id)
ok = False
try:
async with get_db_pool().acquire() as connection:
await guard.restore_rows(connection, str(project_id), rows)
except Exception:
logger.exception("한 본문 저장 되돌림 실패(DB): project_id=%s", project_id)
ok = False
return ok