"""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