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:
2026-10-19 12:07:12 +09:00
co-authored by Claude Opus 5.5
parent de0141b8fc
commit 4b6800e387
48 changed files with 1340 additions and 731 deletions
+58 -15
View File
@@ -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")
+1 -50
View File
@@ -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차 범위).
* · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설
+1
View File
@@ -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 금지
남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도):