Files
Aislo/B06_Section/B06_Section_Router_DesignSave.py
T
eomsangdonandClaude Opus 5.5 b92fa06f34 fix(B05·B06): 노선 고친 [저장] 계획고 = 서버 옮김 위에 사용자 손댐만 (PLAN 498-2 · 요구 411~417)
- 브라우저 — 노선 바뀐 [저장]의 계획고 본문 = 묶음 계획고 대 저장분 옮김(같은 carryEdits · 이 초안 첫 기준)의 다른 열쇠 · 지운 열쇠만(`gradeTouch` · `routeGradeTouch`) · 못 가르면 옛 길(노선 0 칸 통째)
- 서버 — `alignment_cleared` 가 오면 관 · 구조물 · 추가 측점 차례 뒤 새 종단 편집(서버 옮김) 위에 손댐만 얹어 한 번 저장(`touched_alignment` · 얹기 실패도 되돌림 안)
- follow 옮김 측점 = stations 단계와 같은 추가 측점(`plus` 넘김)
- 재현: 꼬리 추가 측점 ▲ 뒤 앞 교점 끌기 [저장] = 990 m 편집 버림 · ▲ 잃음 · 최대 −0.50 m → 고친 뒤 버림 0 · 끌 때 = 저장 뒤
- 시험: test_498_2_grade_touch 새로 · helper_design_store_save 에 묶음 창구 가짜 하나(옛 길)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:51:29 +09:00

338 lines
16 KiB
Python

"""B05 · B06 [저장] · [확정] 한 본문 창구(PLAN 472-6 6-A) — 노선 안 바뀐 저장.
브라우저가 세션 초안 · 사용자 칸만 한 본문으로 보내면 서버가 옛 차례 그대로 안쪽 창구를 부름:
페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 관 옵션 → 상단측(비정규 측점 맞춤) → 횡단
각 안쪽 창구가 정본을 다시 셈(브라우저 결과 안 받음 · 횡단 차례 끝 서버 재계산). 하나라도 실패하면
(오류 응답 · 측점 맞춤 실패 · 재계산 실패 · 하위 차례 실패) DB 행 · storage 를 시작 전으로 되돌리고
그 차례 이름 · 까닭을 돌려줌(`common_util_save_guard` · 브레인 A). 되돌림마저 실패하면 「복구 확인
필요」. 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`waited`).
노선 바뀐 저장(`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 import B06_Section_Router_DesignSave_Progress as progress
from B06_Section.B06_Section_Router_DesignSave_Route import (
AlignmentCleared,
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
#: 노선 바뀐 저장의 계획고 지운 열쇠 — 오면 `alignment` = 사용자 손댐(서버 옮김 위에 얹음 ·
#: 498-2) · 없으면 `alignment` 통째(옛 길)
alignment_cleared: AlignmentCleared | 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 {})
async def _stale_or_false(stale: Any, project_id: UUID) -> bool:
"""진행 이름 고르기만 — 판정 실패는 저장을 막지 않음(관 차례가 다시 판정)."""
try:
return bool(await stale(project_id))
except Exception:
logger.exception("한 본문 저장 배수유역 낡음 판정 실패: project_id=%s", project_id)
return False
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 (
_analysis_stale,
get_pipe_points,
put_pipe_points,
)
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] = {}
# 잠금판 진행(520-4) — 이 본문이 거칠 차례 목록 = N
planned = [
name
for name, on in (
("route", moved),
("settings", request.settings is not None),
("pipes", request.pipes is not None or request.confirm),
("structures", request.structures is not None),
("plusStations", request.plus_stations is not None),
("alignment", request.alignment is not None),
("culvertOptions", bool(request.culvert_options)),
("uphill", request.uphill is not None and not moved),
("cross", moved or request.cross is not None),
)
if on
]
progress.begin(project_id, planned)
async def step(name: str, call: Any, label: str | None = None) -> dict[str, Any]:
progress.mark(project_id, label or name)
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"])
# 관 · 구조물 초안 = 서버가 따라 옮긴 정본 위에(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})
# 노선 바뀜 + 손댐 = 서버 옮김(관 · 구조물 · 추가 측점 차례 뒤 새 종단 편집) 위에 손댐만(498-2)
touched = moved and request.alignment_cleared is not None
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:
# [확정] + 낡은 분석 = 관 차례가 배수유역부터 다시 셈(한 번 · 화면 [업데이트] 없음 · 520-4)
basins = request.confirm and await _stale_or_false(_analysis_stale, project_id)
pipes = await step(
"pipes",
put_pipe_points(project_id, request.pipes or {}, session, request.confirm),
"basins" if basins else None,
)
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:
alignment = request.alignment
if touched:
alignment = moving.touched_alignment(
project_id, route_id, alignment, request.alignment_cleared
)
await step("alignment", _save_alignment(project_id, 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:
progress.mark(project_id, "cross")
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:
crossing = None
if crossing is not None:
cross = await step("cross", crossing)
if cross.get("recomputed") is False:
raise StepFailed("cross", "서버 재계산 실패")
if cross.get("stations_synced") is False:
raise StepFailed("cross", "구조물 측점 맞춤 실패")
# [>](확정) = confirm_sections 가 단계를 닫음 · 그 밖 [저장] = 2 · 3 진행 중 · 뒤 STALE(B1)
sent = any(value is not None for key, value in request if key not in ("route_id", "confirm"))
if sent and not (request.confirm and not moved and request.cross is not None):
try:
await _save_stages(str(project_id))
except Exception as error:
logger.exception("한 본문 저장 단계 상태 실패: project_id=%s", project_id)
raise StepFailed("stages", str(error) or type(error).__name__) from None
return out
async def _save_alignment(project_id: UUID, alignment: Any) -> Any:
"""계획고 차례 — 본문(또는 본문을 내는 약속 · 노선 바뀐 손댐 얹기 · 498-2)을 저장."""
from B05_Profile.B05_Profile_Router import save_profile_alignment
if not isinstance(alignment, ProfileAlignmentSaveRequest):
alignment = await alignment
return await save_profile_alignment(project_id, alignment)
async def _save_stages(project_id: str) -> None:
"""B05 · B06 한 본문 [저장] — 단계 2 · 3(실패면 한 본문 되돌림이 단계 행도 되살림)."""
from common_util.common_util_workflow_state import mark_stage
await mark_stage(project_id, 2, "save")
await mark_stage(project_id, 3, "save")
@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 # 노선 저장 = 관 따라감 · 새 교차 관(배수 폴더)
files = guard.snapshot_storage(
root, guard.STORAGE_SCOPE + (guard.PIPE_SCOPE if pipes 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()
progress.end(project_id)
return JSONResponse(content={"status": "success", "waited": waited, "steps": steps})
@router.get("/{project_id}/design/save/progress")
async def get_design_save_progress(project_id: UUID) -> dict[str, Any]:
"""잠금판 진행 묻기(520-4) — 차례 이름 · n/N · 지난 초 · 안 돌면 active 거짓."""
return progress.snapshot(project_id)
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