feat(472-6 6-B 노선 바뀐 [저장] 한 본문): 노선 저장 · 관 · 구조물 다시 얹기 · 횡단 고침 넘김을 서버 한 창구로 — 전부 아니면 없음
- POST /design/save 에 route(노선 초안) · carry(옛 노선 세션 칸) 칸 · 서버 = 노선 저장 → Node facility.rebase(rebasePipeDraft · rebaseStructureDraft) → 6-A 차례(새 노선) → Node stations.carry(routeCarryPatches) → 횡단 저장 - 되돌림 범위 = 노선 행 · 자식 표 · 단계 · 배수 폴더 · 노선 파일 · 지표면 확정(설계 지형 바꿈) - 손 상단측은 안 넘김(요구 274) · 노선 바뀐 [확정] = [저장](새 노선 본 뒤 다시) - 브라우저 = 노선 초안 + 사용자 칸만 한 본문 · 옛 차례 길(saveRouteDraft 뒤 flush · carryAfterRouteSave · keepDraftsOnNewRoute) · 부름 0 된 flush 창구 여섯 · saveCachedCrossPatches 걷음 - 등록부 R9 stations.carry · F10 facility.rebase · 옛 차례 단언 시험 한 본문 꼴로 · test_472_6_route_save(옛 길 = 새 길 · 실패 되돌림) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,10 @@
|
||||
(오류 응답 · 측점 맞춤 실패 · 재계산 실패 · 하위 차례 실패) DB 행 · storage 를 시작 전으로 되돌리고
|
||||
그 차례 이름 · 까닭을 돌려줌(`common_util_save_guard` · 브레인 A). 되돌림마저 실패하면 「복구 확인
|
||||
필요」. 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`waited`).
|
||||
노선 바뀐 저장(노선 초안)은 아직 옛 길(`/route/replan` 뒤 차례 · 6-B).
|
||||
노선 바뀐 저장(`route` · `carry` 칸 · 6-B) = 맨 앞 노선 저장 → 관 · 구조물 초안 다시 얹기 →
|
||||
위 차례(새 노선) · 횡단 = 옛 노선 세션 칸을 새 측점에 넘긴 patch · 상단측 안 보냄(요구 274) ·
|
||||
늘 [저장](새 노선을 본 뒤 [확정]) · 되돌림 범위에 배수 폴더 · 지표면 확정(설계 지형 바꿈)까지
|
||||
(`B06_Section_Router_DesignSave_Route`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,12 +28,14 @@ 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
|
||||
@@ -60,12 +65,10 @@ class DesignSaveRequest(BaseModel):
|
||||
culvert_options: dict[str, dict[str, Any]] | None = None
|
||||
uphill: RouteConfirmRequest | None = None
|
||||
cross: SectionConfirmRequest | None = None
|
||||
|
||||
|
||||
class StepFailed(Exception):
|
||||
def __init__(self, step: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.step = step
|
||||
#: 노선 초안(`/route/replan` 본문 꼴) — 오면 노선 바뀐 저장(6-B)
|
||||
route: RouteSaveRequest | None = None
|
||||
#: 노선 바뀐 저장의 옛 노선 세션 칸(측점 열쇠 넘김 입력)
|
||||
carry: RouteCarryRequest | None = None
|
||||
|
||||
|
||||
def _body(response: Any) -> dict[str, Any]:
|
||||
@@ -105,6 +108,9 @@ async def _run_steps(
|
||||
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:
|
||||
@@ -119,6 +125,24 @@ async def _run_steps(
|
||||
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:
|
||||
@@ -140,17 +164,34 @@ async def _run_steps(
|
||||
await step(
|
||||
"culvertOptions", put_pipe_points(project_id, {"points": points}, session, False)
|
||||
)
|
||||
if request.uphill is not None:
|
||||
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"]) + " 실패")
|
||||
if request.cross is not None:
|
||||
# 노선 바뀜 = 옛 노선 세션 칸 → 새 측점 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
|
||||
cross = await step("cross", save(project_id, request.route_id, request.cross))
|
||||
if cross.get("recomputed") is False:
|
||||
raise StepFailed("cross", "서버 재계산 실패")
|
||||
if cross.get("stations_synced") is False:
|
||||
raise StepFailed("cross", "구조물 측점 맞춤 실패")
|
||||
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
|
||||
|
||||
|
||||
@@ -167,9 +208,11 @@ async def save_design(
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
rows = await guard.snapshot_rows(connection, str(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 ())
|
||||
)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""노선 바뀐 [저장] 한 본문 몫(PLAN 472-6 6-B) — `B06_Section_Router_DesignSave` 가 부름.
|
||||
|
||||
노선 초안이 오면 차례 맨 앞:
|
||||
|
||||
노선 저장(`/route/replan` 체인 · 관 · 구조물 · 추가 측점 좌표 따라감 · 종횡단 · 계획고 ·
|
||||
횡단 이월)
|
||||
→ 관 · 구조물 초안 다시 얹기(Node `facility.rebase` · 서버 따라감 위에)
|
||||
→ 6-A 차례(새 노선) → 횡단 = 옛 노선 세션 칸을 새 측점에 넘긴 patch(Node `stations.carry`)
|
||||
|
||||
브라우저가 하던 다시 얹기(`rebasePipeDraft` · `carryAfterRouteSave`)와 같은 TS 를 서버가 부름 —
|
||||
브라우저는 노선 초안 + 사용자 칸만 보냄. 손 상단측은 안 넘김(요구 274 · 새 지형이 정함).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from common_util.common_util_calc_runner import run_calc
|
||||
|
||||
|
||||
class RouteCarryRequest(BaseModel):
|
||||
"""옛 노선 세션 칸 — 브라우저 `routeCarryInputs` 꼴 그대로(넘김 규칙은 TS 한 벌)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
#: 칸 이름 → 세션 원본(측점 열쇠 칸 다섯 · 누가거리 칸 · `kindreset`)
|
||||
states: dict[str, Any] = Field(default_factory=dict)
|
||||
#: 옛 노선 측점 열쇠 목록 [{key, chainageM}] · 브라우저가 못 세웠으면 null
|
||||
from_keys: list[dict[str, Any]] | None = None
|
||||
#: 측점을 덮는 관 · 구조물 [{id, kind, at, role}]
|
||||
owners: list[dict[str, Any]] = Field(default_factory=list)
|
||||
#: 관 세션 장부 {next, ids: [{id, at}]}
|
||||
pipe_ids: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class StepFailed(Exception):
|
||||
def __init__(self, step: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.step = step
|
||||
|
||||
|
||||
def _node(ids: list[str], inputs: dict[str, Any], step: str) -> dict[str, Any]:
|
||||
out = run_calc(ids, inputs)
|
||||
if not isinstance(out, dict) or ids[0] not in out:
|
||||
raise StepFailed(step, "다시 얹기 계산(Node)을 못 돌렸습니다")
|
||||
return out[ids[0]]
|
||||
|
||||
|
||||
def _pipes(listing: Any) -> list[dict[str, Any]]:
|
||||
"""관 GET 응답 → 관 목록(지도용 lonlat 뺌 · 브라우저 `fetchDetailPipePoints` 짝)."""
|
||||
points = listing.get("pipe_points") if isinstance(listing, dict) else None
|
||||
return [{k: v for k, v in point.items() if k != "lonlat"} for point in points or []]
|
||||
|
||||
|
||||
async def saved_facilities(project_id: UUID) -> dict[str, Any]:
|
||||
"""노선 저장 직전 저장 관 · 구조물(다시 얹기 · 넘김의 `before`)."""
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points
|
||||
from B05_Profile.B05_Profile_Structures_Router import read_structures
|
||||
|
||||
pipes = await get_pipe_points(project_id)
|
||||
structures = await read_structures(project_id)
|
||||
return {
|
||||
"pipes": _pipes(pipes),
|
||||
"structures": [
|
||||
item.model_dump(mode="json") for item in getattr(structures, "structures", None) or []
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def rebase_drafts(project_id: UUID, request: Any, before: dict[str, Any]) -> None:
|
||||
"""관 · 구조물 초안을 서버가 따라 옮긴 정본 위에(Node `facility.rebase`) — 본문을 제자리에서
|
||||
고침.
|
||||
|
||||
구조물 판번호 = 노선 저장 뒤 지금 판(따라감이 판을 올림 · 옛 길 = 저장 뒤 받은 판).
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points
|
||||
from B05_Profile.B05_Profile_Structures_Router import read_structures
|
||||
|
||||
rebase: dict[str, Any] = {}
|
||||
points = (request.pipes or {}).get("points") if isinstance(request.pipes, dict) else None
|
||||
if points is not None:
|
||||
after = _pipes(await get_pipe_points(project_id))
|
||||
rebase["pipes"] = {"draft": points, "before": before["pipes"], "after": after}
|
||||
stored = None
|
||||
if request.structures is not None:
|
||||
stored = await read_structures(project_id)
|
||||
draft = [item.model_dump(mode="json") for item in request.structures.structures]
|
||||
after = [item.model_dump(mode="json") for item in stored.structures]
|
||||
rebase["structures"] = {"draft": draft, "before": before["structures"], "after": after}
|
||||
if not rebase:
|
||||
return
|
||||
out = _node(["facility.rebase"], {"rebase": rebase}, "route")
|
||||
if out.get("pipes") is not None:
|
||||
request.pipes = {**request.pipes, "points": out["pipes"]}
|
||||
if out.get("structures") is not None and stored is not None:
|
||||
request.structures = type(request.structures).model_validate(
|
||||
{"base_revision": stored.revision, "structures": out["structures"]}
|
||||
)
|
||||
|
||||
|
||||
async def carry_cross(
|
||||
project_id: UUID,
|
||||
route_id: int,
|
||||
carry: RouteCarryRequest | None,
|
||||
before: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""옛 노선 세션 칸 → 새 노선 횡단 patch(Node `stations.carry`) → {patches, outside}."""
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points
|
||||
from B06_Section.B06_Section_Router import get_section_detail
|
||||
|
||||
detail = await get_section_detail(project_id, route_id)
|
||||
if not hasattr(detail, "model_dump"):
|
||||
raise StepFailed("cross", "새 노선 횡단 상세를 못 읽었습니다")
|
||||
inputs = {
|
||||
"detail": detail.model_dump(mode="json"),
|
||||
"pipes_before": before["pipes"],
|
||||
"pipes_after": _pipes(await get_pipe_points(project_id)),
|
||||
**(carry or RouteCarryRequest()).model_dump(mode="json"),
|
||||
}
|
||||
return _node(["stations.carry"], {"carry": inputs}, "cross")
|
||||
@@ -36,7 +36,6 @@ import type {
|
||||
CrossSection,
|
||||
CrossSectionPatch,
|
||||
SectionDetailResponse,
|
||||
StandardCrossSection,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
forgetCanon,
|
||||
@@ -48,7 +47,7 @@ import {
|
||||
savedDesigns,
|
||||
sessionDetail,
|
||||
} from "./B06_Section_Section_Canon";
|
||||
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
|
||||
import { fetchSectionDetail } from "./B06_Section_Api_Fetch";
|
||||
import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
|
||||
import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options";
|
||||
import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
@@ -65,10 +64,6 @@ import {
|
||||
} from "./B06_Section_Station_Keys";
|
||||
import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap";
|
||||
import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches";
|
||||
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
||||
import { readCutSlopeSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||
import { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel";
|
||||
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
|
||||
import { emitSwap, type DetailListener } from "./B06_Section_Swap_Bus";
|
||||
|
||||
export { onAnySwap } from "./B06_Section_Swap_Bus";
|
||||
@@ -470,47 +465,3 @@ export function cachedCrossPatches(
|
||||
: [];
|
||||
return mergeCrossPatches(base, overlay);
|
||||
}
|
||||
|
||||
/** 노선 저장 뒤 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 횡단 보기 [저장] 본문과 같은 칸(445 H5). */
|
||||
function sessionOverlay(projectId: string, routeId: number): CrossSectionPatch[] {
|
||||
const out: CrossSectionPatch[] = [];
|
||||
crossDesignChoices(projectId, routeId).forEach((choice, chainage) => {
|
||||
const patch: CrossSectionPatch = {
|
||||
chainage_m: chainage,
|
||||
ground_type: choice.ground_type,
|
||||
section_mode: choice.section_mode,
|
||||
};
|
||||
if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side;
|
||||
if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type;
|
||||
if (choice.paved !== undefined) patch.paved = choice.paved;
|
||||
if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope;
|
||||
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
|
||||
out.push(patch);
|
||||
});
|
||||
const rock = readRockBoundarySession(projectId, routeId) ?? {};
|
||||
for (const [chainage, offset] of Object.entries(rock))
|
||||
if (typeof offset === "number")
|
||||
out.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset });
|
||||
for (const [chainage, ratio] of Object.entries(readCutSlopeSession(projectId, routeId)))
|
||||
out.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio });
|
||||
return out.filter((patch) => Number.isFinite(patch.chainage_m));
|
||||
}
|
||||
|
||||
/**
|
||||
* 노선 저장 뒤 고침을 영구저장소에 남긴다 — 횡단 보기 [저장]과 같은 본문(캐시 사용자 칸 · 카드 선택 · 암선 ·
|
||||
* 암 경사 · 표준 횡단면 · 풍선 자리 · 445 H5). 수정분이 없어도 한 번 보냄 — 서버가 지금 종단으로 횡단을 다시 셈
|
||||
* (429 ④ · 노선 뒤에 나간 계획고 초안). 저장 실패는 호출자가 처리한다.
|
||||
*/
|
||||
export async function saveCachedCrossPatches(projectId: string, routeId: number): Promise<number> {
|
||||
const patches = cachedCrossPatches(projectId, routeId, sessionOverlay(projectId, routeId));
|
||||
const standard = readStandardCrossSession(projectId) as StandardCrossSection | null;
|
||||
await saveSections(
|
||||
projectId,
|
||||
routeId,
|
||||
standard ?? undefined,
|
||||
patches.length ? patches : undefined,
|
||||
{ balloon_offsets: balloonOffsetsPayload() },
|
||||
);
|
||||
clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4)
|
||||
return patches.length;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로.
|
||||
* · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고
|
||||
* [임시저장]·[확정]의 `flushPendingStructures`가 정본에 쓴다(캐시→저장 원칙).
|
||||
* [저장]·[확정] 한 본문(`structuresPart`)이 정본에 쓴다(캐시→저장 원칙).
|
||||
* · 계곡 통과 시설(A군, pipe_points 정본)은 **표시·선택만** — 추가·이동·삭제는
|
||||
* 배수유역 재분할 체인이 있는 B05 몫이라 안내만 한다(2026-08-29 1차 범위).
|
||||
* · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
- 입구 `B06_Section_Server_Calc_Node.ts`(package.json `build:server-calc`) · 끌어감 = `UI_Cross_Culvert*` · `_Box_Geom` · `_Ford_Geom` · `_Wall*` · `_Revetment` · `_Lean` · `_Excavation` · `_Structure_Cut` · `_Body_Guard` · `Structure_Layouts` · `UI_Section_Common`
|
||||
- `build:corridor` · `build:b07-extent` 도 `UI_Cross_*` 구조물 그림을 끌어감 — 고치면 해당 build 다시 지어 시험
|
||||
- `build:design-calc`(A00 `design_calc_node`)가 `Route_Carry_Patch` · `logic/Route_Carry` · `logic/Design_Keys` 를 끌어감(노선 바뀐 [저장] 넘김) — 고치면 다시 지어 `test_472_6_route_save`
|
||||
- 입구 경로를 시험이 씀(`test_b06_ground_unlock.py` · `test_b06_height_unlock.py` 등) · 묶음이 끄는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지
|
||||
|
||||
남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도):
|
||||
|
||||
Reference in New Issue
Block a user