- B06 [>] · 단계 판 B07~B09 = moveStage 한 단계(설계 확정) · progress = GET /design/save/progress 1 s 물음 · B03 · B04 로 = moveStage 잠금 길(떠남 확인은 잠금 풀린 뒤) - 낡음 확인 창 · 화면 side.update() 걷음 — 배수유역은 서버 [확정] 관 차례가 한 번만 셈 · 진행 이름 「배수유역 다시 셈」(basins) - 서버 _run_steps step() 진행을 메모리(B06_Section_Router_DesignSave_Progress) · 차례 목록 = N · 끝에 비움 - 노선 바뀐 채 [>] = 물음(잠금판 위 · z 1050 > 900) → 다시 세운 페이지 둘째 확정을 저장 줄 밖에서 기다림 → 잠금판이 다시 마운트를 넘어 이어져 이동 · 120 s 넘으면 머묾 - saveSharedDesign 이 참 · 거짓을 돌려줌 · 확정 이동은 부른 쪽(moveStage) - 낡은 시험 고침 — 512_2(확인 창 → 잠금판 한 단계 · 서버 basins) · 298_3 · 272 · b06_save_channels · b06_save_recompute(글 약속 새 길) - helper_ts_stub_loader .css = 빈 모듈 — 520-3 ui_template_progress.css import 로 451 · 384_4 · 416 등 helper 시험이 깨짐(브레인 승인) - 새 시험 test_520_4_save_progress(n/N · 배수유역 자리) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
15 KiB
Python
316 lines
15 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 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 {})
|
|
|
|
|
|
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 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] = {}
|
|
# 잠금판 진행(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})
|
|
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:
|
|
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:
|
|
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_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
|