fix(B05·B06): 노선 [확인] 뒤 옛 노선 id 저장 막음 — 서버 409 · 저장 앞 노선 대조 · 노선 바뀌면 페이지 새로 세움(76-1)
- 서버: sections save · confirm 이 최신 아닌 노선 id 를 409 로 거절(옛 노선 재계산 없음) - 화면: [저장] · [확정] 앞에서 서버 노선과 대조 · 다르면 아무것도 안 보내고 페이지 새로 세움 - 화면: 종단 보기 신선도 확인에서 노선이 바뀌면 선만 다시 그리지 않고 페이지 새로 세움 - 시험: test_76_stale_route_save · 기존 저장 시험에 최신 노선 조회 가짜값 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
@@ -15,7 +15,9 @@ import { showToast } from "@ui/ui_template_elements";
|
||||
import { splitSidebarActions } from "@ui/ui_template_overlay";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
|
||||
import { navigateTo } from "../router";
|
||||
import { currentRoute, navigateTo, remountRoute } from "../router";
|
||||
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
|
||||
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
@@ -253,11 +255,28 @@ export function designReady(shared: DesignPageShared): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면이 쥔 노선이 서버의 지금 노선과 다른가 — 다르면 아무것도 보내지 않고 페이지를 새로 세운다.
|
||||
* 노선 [확인] · 다른 탭으로 노선이 갈렸는데 옛 노선 id 로 저장이 나가 옛 노선을 다시 계산했다
|
||||
* (PLAN 76-1). 묻기가 실패하면 그대로 저장한다 — 서버도 옛 노선 저장은 409 로 거절한다.
|
||||
*/
|
||||
async function routeChanged(shared: DesignPageShared): Promise<boolean> {
|
||||
const shown = routeIdOf(shared);
|
||||
if (shown === null) return false;
|
||||
const current = await fetchCurrentRouteId(shared.projectId).catch(() => shown);
|
||||
if (current === shown) return false;
|
||||
showToast(L("B06_Profile_Route_Changed"), "error");
|
||||
clearRouteLatestCache(shared.projectId);
|
||||
remountRoute(currentRoute());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
|
||||
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳.
|
||||
*/
|
||||
export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise<void> {
|
||||
if (await routeChanged(shared)) return;
|
||||
// [확정]은 횡단 본문(표준 횡단면 · 전 측점 면적)이 있어야 한다 — 숨긴 채라도 횡단 보기를 세운다.
|
||||
if (confirm) await shared.ensureView("cross");
|
||||
const profileSave = shared.bindings.profile?.save;
|
||||
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
type SurfaceConfirmedResponse,
|
||||
} from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
clearRouteLatestCache,
|
||||
updateContourInterval,
|
||||
type RouteLatestResponse,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
|
||||
import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
|
||||
import { leaveForDashboard } from "../b_missing_data_guard";
|
||||
import { remountRoute } from "../router";
|
||||
import { currentRoute, remountRoute } from "../router";
|
||||
import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
|
||||
import { createSelectionSync } from "../../B05_Profile/B05_Profile_UI_Selection";
|
||||
import {
|
||||
@@ -640,6 +641,13 @@ export async function mountProfileView(
|
||||
// 캐시로 그렸다면 뒤에서 신선도만 확인한다 — 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다.
|
||||
if (cachedLatest) {
|
||||
void latestSource.verify(latestResponse, async (fresh) => {
|
||||
// 노선 자체가 바뀌었으면 횡단 보기 · 측점 · 구조물도 옛 노선을 쥐고 있다 — 선만 다시 그리면
|
||||
// 옛 노선 표지 · 옛 id 저장이 남는다(PLAN 76-1). 설정값 캐시를 버리고 페이지를 새로 세운다.
|
||||
if (fresh.route?.id !== latestResponse.route?.id) {
|
||||
clearRouteLatestCache(activeProjectId);
|
||||
remountRoute(currentRoute());
|
||||
return;
|
||||
}
|
||||
renderLatest(fresh);
|
||||
if (fresh.route?.id) await restoreSections(fresh.route.id);
|
||||
});
|
||||
|
||||
@@ -72,6 +72,17 @@ export async function fetchSectionContext(projectId: string): Promise<SectionCon
|
||||
return seedStandardCross(projectId, fresh);
|
||||
}
|
||||
|
||||
/** 서버가 지금 보는 노선 id — 세션 캐시를 건너뛰고 묻는다. [저장] · [확정] 앞에서 화면이 쥔
|
||||
* 노선과 대조한다(PLAN 76-1). 받은 설정값으로 캐시도 새로 둔다. */
|
||||
export async function fetchCurrentRouteId(projectId: string): Promise<number | null> {
|
||||
const fresh = await requestJson<SectionContextResponse>(
|
||||
`/projects/${projectId}/sections/context`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
writeState("section-context", fresh, projectId);
|
||||
return fresh.route_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 표준 횡단면을 **세션이 비어 있을 때만** 채운다(2026-09-07).
|
||||
*
|
||||
|
||||
@@ -30,6 +30,7 @@ from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_cross_sections_missing_design_chainages,
|
||||
get_longitudinal_section,
|
||||
get_workflow_route_context,
|
||||
merge_longitudinal_section_data,
|
||||
merge_longitudinal_section_options,
|
||||
)
|
||||
@@ -179,6 +180,28 @@ async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _stale_route_response(
|
||||
connection: aiomysql.Connection, project_id: UUID, route_id: int
|
||||
) -> JSONResponse | None:
|
||||
"""화면이 쥔 노선이 **최신 노선이 아니면** 409 — 노선 [확인]으로 갈린 옛 노선에 저장이
|
||||
나가 옛 노선을 다시 계산하지 않게 한다(PLAN 76-1). 화면은 받으면 페이지를 다시 세운다."""
|
||||
context = await get_workflow_route_context(connection, project_id)
|
||||
latest = context.get("route_id") if context else None
|
||||
if latest is None or int(latest) == route_id:
|
||||
return None
|
||||
logger.warning(
|
||||
"옛 노선 저장 거절: project_id=%s route_id=%s 최신=%s", project_id, route_id, latest
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "노선이 바뀌었습니다 — 화면을 다시 읽은 뒤 저장하세요.",
|
||||
"latest_route_id": int(latest),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/save", response_model=SectionConfirmResponse)
|
||||
async def save_sections(
|
||||
project_id: UUID,
|
||||
@@ -203,6 +226,9 @@ async def save_sections(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "저장할 종횡단이 없습니다."},
|
||||
)
|
||||
stale = await _stale_route_response(connection, project_id, route_id)
|
||||
if stale:
|
||||
return stale
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
known = await get_cross_section_chainages(connection, route_id)
|
||||
# 행은 있는데 **설계가 빈** 측점도 채운다(2026-09-09). 「빈 설계」란 `design` 이
|
||||
@@ -281,6 +307,9 @@ async def confirm_sections(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정할 종횡단이 없습니다."},
|
||||
)
|
||||
stale = await _stale_route_response(connection, project_id, route_id)
|
||||
if stale:
|
||||
return stale
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
|
||||
known = await get_cross_section_chainages(connection, route_id)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""노선 [확인] 뒤 옛 노선 id 로 저장이 나가지 않는지 (PLAN 76-1).
|
||||
|
||||
사용자 실측(226fc805): 노선 264 → 267 로 갈린 뒤 [저장]이 `sections/264/save` 로 나가 서버가
|
||||
옛 노선 264 를 다시 계산했다. 서버는 최신 아닌 노선 저장을 409 로 거절하고, 화면은 저장 앞에서
|
||||
서버 노선과 대조해 다르면 아무것도 안 보내고 페이지를 새로 세운다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import B06_Section.B06_Section_Router_Confirm as confirm # noqa: E402
|
||||
|
||||
PID = UUID("fa76c162-71c7-46e5-a95d-fb3930665a45")
|
||||
|
||||
|
||||
class _Conn:
|
||||
async def begin(self) -> None:
|
||||
return None
|
||||
|
||||
async def commit(self) -> None:
|
||||
return None
|
||||
|
||||
async def rollback(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self) -> _Conn:
|
||||
return _Conn()
|
||||
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Pool:
|
||||
def acquire(self) -> _Acquire:
|
||||
return _Acquire()
|
||||
|
||||
|
||||
def _async(value: Any):
|
||||
async def _inner(*_: Any, **__: Any) -> Any:
|
||||
return value
|
||||
|
||||
return _inner
|
||||
|
||||
|
||||
def _wire(monkeypatch: pytest.MonkeyPatch, latest: int) -> list[str]:
|
||||
"""최신 노선을 `latest` 로 두고, 저장 본체가 불렸는지 적는 목록을 돌려준다."""
|
||||
called: list[str] = []
|
||||
|
||||
async def _edits(*_: Any, **__: Any) -> None:
|
||||
called.append("edits")
|
||||
|
||||
async def _recompute(*_: Any, **__: Any) -> None:
|
||||
called.append("recompute")
|
||||
|
||||
monkeypatch.setattr(confirm, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(
|
||||
confirm, "get_longitudinal_section", _async({"longitudinal_file_path": "long.json"})
|
||||
)
|
||||
monkeypatch.setattr(confirm, "get_workflow_route_context", _async({"route_id": latest}))
|
||||
monkeypatch.setattr(confirm, "get_project_storage_relative_path", _async("1/3/proj"))
|
||||
monkeypatch.setattr(confirm, "get_cross_section_chainages", _async([0.0, 20.0]))
|
||||
monkeypatch.setattr(confirm, "get_cross_sections_missing_design_chainages", _async([]))
|
||||
monkeypatch.setattr(confirm, "resolve_stored_project_path", lambda _p: str(PROJECT_ROOT))
|
||||
monkeypatch.setattr(confirm, "_rowless_station_chainages", lambda *_a, **_k: [])
|
||||
monkeypatch.setattr(confirm, "_apply_section_edits", _edits)
|
||||
monkeypatch.setattr(confirm, "_recompute_stored_designs", _recompute)
|
||||
return called
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["save_sections", "confirm_sections"])
|
||||
def test_옛_노선_저장은_409(monkeypatch: pytest.MonkeyPatch, endpoint: str) -> None:
|
||||
called = _wire(monkeypatch, latest=267)
|
||||
response = asyncio.run(getattr(confirm, endpoint)(project_id=PID, route_id=264, request=None))
|
||||
assert response.status_code == 409
|
||||
assert json.loads(response.body)["latest_route_id"] == 267
|
||||
assert called == [] # 저장 · 옛 노선 재계산 둘 다 안 돎
|
||||
|
||||
|
||||
def test_최신_노선_저장은_그대로(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
called = _wire(monkeypatch, latest=267)
|
||||
response = asyncio.run(confirm.save_sections(project_id=PID, route_id=267, request=None))
|
||||
assert getattr(response, "confirmed", None) is False, response
|
||||
assert called == ["edits", "recompute"]
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
return (PROJECT_ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_화면은_저장_앞에서_노선을_대조한다() -> None:
|
||||
"""[저장] · [확정] 첫 줄이 서버 노선 대조 — 다르면 아무 단계도 안 보내고 페이지를 새로 세움."""
|
||||
source = _read("A00_Common/design/design_shared.ts")
|
||||
body = source.split("export async function saveSharedDesign", 1)[1]
|
||||
assert body.index("routeChanged(shared)") < body.index("saveDesign(")
|
||||
guard = source.split("async function routeChanged", 1)[1].split("\n}\n", 1)[0]
|
||||
assert "fetchCurrentRouteId" in guard and "remountRoute" in guard
|
||||
|
||||
|
||||
def test_신선도_확인에서_노선이_바뀌면_페이지를_새로_세운다() -> None:
|
||||
"""캐시로 그린 뒤 서버 노선이 다르면 선만 다시 그리지 않음 — 횡단 보기 · 측점 · 구조물이 옛 노선."""
|
||||
source = _read("A00_Common/design/design_view_profile.ts")
|
||||
verify = source.split("latestSource.verify(latestResponse", 1)[1].split("});", 1)[0]
|
||||
assert "fresh.route?.id !== latestResponse.route?.id" in verify
|
||||
assert verify.index("remountRoute(") < verify.index("renderLatest(fresh)")
|
||||
@@ -64,6 +64,8 @@ def _run_save(monkeypatch: pytest.MonkeyPatch, missing: list[float]) -> list[flo
|
||||
confirm, "get_longitudinal_section", _async({"longitudinal_file_path": "long.json"})
|
||||
)
|
||||
monkeypatch.setattr(confirm, "get_project_storage_relative_path", _async("1/3/proj"))
|
||||
# 화면이 쥔 노선 = 최신 노선(옛 노선 저장 거절은 `test_76_stale_route_save`).
|
||||
monkeypatch.setattr(confirm, "get_workflow_route_context", _async({"route_id": 184}))
|
||||
monkeypatch.setattr(confirm, "get_cross_section_chainages", _async([0.0, 20.0, 40.0]))
|
||||
monkeypatch.setattr(confirm, "get_cross_sections_missing_design_chainages", _async(missing))
|
||||
monkeypatch.setattr(confirm, "resolve_stored_project_path", lambda _p: str(PROJECT_ROOT))
|
||||
|
||||
@@ -207,6 +207,10 @@ export const ui_locales_b2 = {
|
||||
],
|
||||
B06_Profile_Save_Success: ["편집 내용을 저장했습니다.", "Changes saved."],
|
||||
B06_Profile_Save_Failed: ["저장에 실패했습니다.", "Failed to save."],
|
||||
B06_Profile_Route_Changed: [
|
||||
"노선이 바뀌어 화면을 다시 읽습니다 — 저장하지 않았습니다. 다시 눌러 주세요.",
|
||||
"The route changed — the page was reloaded and nothing was saved. Please try again.",
|
||||
],
|
||||
B06_Profile_Result_Title: ["종·횡단 생성 결과", "Section Result"],
|
||||
B06_Profile_Context_Failed: [
|
||||
"경로 정보를 불러오지 못했습니다. 서버 상태를 확인하세요.",
|
||||
|
||||
Reference in New Issue
Block a user