feat(B03,B04,B05): 초기 설계가 도는 동안 B05·B06 진입을 막는다

파일을 올리면 지표면 확정 직후 "노선 설계로 이동하세요"를 적고 그 뒤에 초기 설계
체인이 돈다. 그 사이 B05에 들어가 만지면 반쯤 계산된 값을 편집하게 되고, 그 편집이
섞인 채 초기값 스냅샷이 찍힌다. 계산이 끝날 때까지 준비 화면(B11)에서 기다리게 한다.

- initial_snapshot: 마커(initial_design.lock) 생성·해제·조회와 스냅샷 무효화,
  편집 정본 제거를 한곳에 둔다. 마커는 스냅샷 4트리 밖(프로젝트 루트)이라 복원에
  딸려 들어가지 않는다.
- 체인 두 곳(자동·재설계): 시작에 마커, finally에서 해제. 재설계 체인은 끝에 스냅샷을
  무효화한다 — 사용자 입력을 유지한 채 재계산하므로 그 결과를 찍으면 가짜 초기값이
  된다. 다음 [초기화]가 폴백을 타며 새 초기값을 만들고 그때 촬영한다.
- /surface/status: 마커가 있으면 in_progress + initial_design으로 답한다. stage 1이
  COMPLETE면 진행률 파일을 무시하는 기존 분기를 건드리지 않으려고 별도 확인이다.
- 진입 게이트(routeAfterPreloadCheck)와 B11이 그 상태를 보고 기다린다. 상태 조회가
  실패하면 막지 않고, B11 대기는 20분 상한을 둔다.
- [초기화] 폴백: 체인 전에 structures.json과 drainage/edits를 지운다. 남기면 구조물
  측점이 사용자 편집분에서 다시 파생돼 초기값이 오염된다(2026-08-29 실측).

테스트 4건 추가. 249 passed·8 failed(기슭막이 이관 때 생긴 기존 실패, 변경 전 동일).
This commit is contained in:
2026-08-29 15:09:09 +09:00
parent cb8cf2c9d2
commit f99f1f68d3
6 changed files with 171 additions and 3 deletions
+16 -1
View File
@@ -5,7 +5,10 @@ import {
type RoutePath, type RoutePath,
} from "@config/config_frontend"; } from "@config/config_frontend";
import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; import type { WorkflowStage } from "@ui/ui_template_workflow_layout";
import { fetchConfirmedSurface } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import {
fetchConfirmedSurface,
fetchSurfaceStatus,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { navigateTo } from "./router"; import { navigateTo } from "./router";
@@ -63,6 +66,18 @@ export function goToWorkflowStage(projectId: string, route: RoutePath): void {
* 확인에 실패하면 준비 화면으로 보내 거기서 사유를 안내한다. * 확인에 실패하면 준비 화면으로 보내 거기서 사유를 안내한다.
*/ */
async function routeAfterPreloadCheck(projectId: string, route: RoutePath): Promise<void> { async function routeAfterPreloadCheck(projectId: string, route: RoutePath): Promise<void> {
// 초기 설계 계산이 끝나기 전에는 담아 둔 자료가 있어도 들여보내지 않는다 — 반쯤 계산된
// 화면을 만지면 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정). 준비 화면이
// 끝날 때까지 기다렸다가 목표 화면으로 넘긴다.
try {
if ((await fetchSurfaceStatus(projectId)).current_stage === "initial_design") {
setPreloadTarget(route);
navigateTo(ROUTES.B11_LOADING);
return;
}
} catch {
/* 상태 조회 실패는 막지 않는다 — 종전 판정(확정 지표면 서명)으로 이어간다. */
}
let signature: string | null = null; let signature: string | null = null;
try { try {
signature = (await fetchConfirmedSurface(projectId)).signature; signature = (await fetchConfirmedSurface(projectId)).signature;
+32 -2
View File
@@ -143,12 +143,17 @@ async def run_auto_design_chain(
from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route
from B05_Profile.B05_Profile_Schema import RoutePoint, RouteSolveRequest from B05_Profile.B05_Profile_Schema import RoutePoint, RouteSolveRequest
from B06_Section.B06_Section_Router_Confirm import confirm_sections from B06_Section.B06_Section_Router_Confirm import confirm_sections
from common_util.common_util_initial_snapshot import save_initial_snapshot from common_util.common_util_initial_snapshot import (
clear_designing,
mark_designing,
save_initial_snapshot,
)
from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import surface_confirmation_defaults from common_util.common_util_surface_confirmation import surface_confirmation_defaults
from config.config_db import get_db_pool from config.config_db import get_db_pool
pool = get_db_pool() pool = get_db_pool()
project_root: Path | None = None
try: try:
# 1) 수동 이력 보호 — 경로가 이미 있으면(사용자 작업 또는 이전 자동 실행) 건너뛴다. # 1) 수동 이력 보호 — 경로가 이미 있으면(사용자 작업 또는 이전 자동 실행) 건너뛴다.
async with pool.acquire() as connection: async with pool.acquire() as connection:
@@ -164,6 +169,9 @@ async def run_auto_design_chain(
# 2) 계획노선 CSV → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다. # 2) 계획노선 CSV → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다.
project_root = Path(resolve_stored_project_path(stored_path)) project_root = Path(resolve_stored_project_path(stored_path))
# 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면
# 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장).
mark_designing(project_root)
points = _planned_route_points_in_project_crs(project_root) points = _planned_route_points_in_project_crs(project_root)
if not points: if not points:
logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id)
@@ -249,6 +257,10 @@ async def run_auto_design_chain(
except Exception: except Exception:
# 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다. # 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다.
logger.exception("자동 설계 체인 실패: project_id=%s", project_id) logger.exception("자동 설계 체인 실패: project_id=%s", project_id)
finally:
# 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다.
if project_root is not None:
clear_designing(project_root)
async def run_redesign_chain( async def run_redesign_chain(
@@ -280,14 +292,29 @@ async def run_redesign_chain(
) )
from B06_Section.B06_Section_Router_Confirm import confirm_sections from B06_Section.B06_Section_Router_Confirm import confirm_sections
from B06_Section.B06_Section_Schema import SectionConfirmRequest from B06_Section.B06_Section_Schema import SectionConfirmRequest
from common_util.common_util_initial_snapshot import (
clear_designing,
discard_initial_snapshot,
mark_designing,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import get_workflow_state from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool from config.config_db import get_db_pool
_ = get_project_storage_relative_path # 시그니처 정렬용 (사용 안 함)
pool = get_db_pool() pool = get_db_pool()
project_root: Path | None = None
try: try:
async with pool.acquire() as connection: async with pool.acquire() as connection:
latest = await get_latest_route(connection, project_id) latest = await get_latest_route(connection, project_id)
stored_path = await get_project_storage_relative_path(connection, project_id)
if stored_path:
project_root = Path(resolve_stored_project_path(stored_path))
mark_designing(project_root)
# 지표면이 바뀌면 옛 초기값은 다른 지형 기준이라 더는 기준이 아니다. 여기서
# 다시 뜨지는 않는다 — 이 체인은 **사용자 입력을 유지한 채** 재계산하므로 그
# 결과를 찍으면 편집이 섞인 가짜 초기값이 된다(2026-08-29 사용자 확정).
if discard_initial_snapshot(project_root):
logger.info("재확정 체인: 초기값 스냅샷 무효화 (project_id=%s)", project_id)
if not latest: if not latest:
logger.info( logger.info(
"재확정 체인 → 기존 경로 없음, 신규 자동 체인으로: project_id=%s", project_id "재확정 체인 → 기존 경로 없음, 신규 자동 체인으로: project_id=%s", project_id
@@ -433,3 +460,6 @@ async def run_redesign_chain(
) )
except Exception: except Exception:
logger.exception("재확정 체인 실패: project_id=%s", project_id) logger.exception("재확정 체인 실패: project_id=%s", project_id)
finally:
if project_root is not None:
clear_designing(project_root)
+14
View File
@@ -45,6 +45,7 @@ from B04_PreProcess.B04_PreProcess_Schema import (
from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection
from common_util.common_util_auth import require_system_admin from common_util.common_util_auth import require_system_admin
from common_util.common_util_http_cache import cached_file_response from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_initial_snapshot import is_designing
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import ( from common_util.common_util_surface_confirmation import (
@@ -594,6 +595,19 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
if not message: if not message:
message = "WF1 분석 대기 중입니다." message = "WF1 분석 대기 중입니다."
# 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도
# 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장).
# 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다.
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))):
status = "in_progress"
current_stage = "initial_design"
message = "초기 설계를 계산하는 중입니다."
except (LookupError, OSError, ValueError):
pass
return { return {
"project_id": str(project_id), "project_id": str(project_id),
"status": status, "status": status,
+10
View File
@@ -626,6 +626,7 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
has_initial_snapshot, has_initial_snapshot,
restore_initial_snapshot, restore_initial_snapshot,
restore_snapshot_files, restore_snapshot_files,
wipe_edited_masters,
) )
from common_util.common_util_surface_confirmation import surface_confirmation_defaults from common_util.common_util_surface_confirmation import surface_confirmation_defaults
@@ -664,6 +665,15 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
# 초기값이 오염된다(2026-08-29). # 초기값이 오염된다(2026-08-29).
await asyncio.to_thread(restore_snapshot_files, project_root) await asyncio.to_thread(restore_snapshot_files, project_root)
else: else:
# 스냅샷이 없어 재계산으로 초기값을 만드는 경로 — 편집 정본을 먼저 걷어내야
# 진짜 초기값이 나온다. 남기면 구조물 측점이 사용자 편집분에서 다시 파생된다
# (2026-08-29 실측). 체인이 끝나며 그 결과를 초기값으로 촬영한다.
if project_root:
removed = await asyncio.to_thread(wipe_edited_masters, project_root)
if removed:
logger.info(
"B05 초기화: 편집 정본 제거 %s (project_id=%s)", removed, project_id
)
await run_auto_design_chain(project_id, surface_model_id=surface_model_id) await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
async with pool.acquire() as connection: async with pool.acquire() as connection:
+27
View File
@@ -11,6 +11,7 @@ import {
readPreloadTarget, readPreloadTarget,
} from "../A00_Common/b_asset_cache"; } from "../A00_Common/b_asset_cache";
import { navigateTo } from "../A00_Common/router"; import { navigateTo } from "../A00_Common/router";
import { fetchSurfaceStatus } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import "./B11_Status_UI_Style.css"; import "./B11_Status_UI_Style.css";
/* ============================================================================= /* =============================================================================
@@ -26,6 +27,29 @@ import "./B11_Status_UI_Style.css";
/** 준비에 실패한 채 그냥 넘어갔을 때 남기는 표식 — 어떤 확정 구성과도 일치하지 않는다. */ /** 준비에 실패한 채 그냥 넘어갔을 때 남기는 표식 — 어떤 확정 구성과도 일치하지 않는다. */
const PRELOAD_SKIPPED_SIGNATURE = "skipped"; const PRELOAD_SKIPPED_SIGNATURE = "skipped";
/** 초기 설계 계산이 끝날 때까지 기다린다. 상태 조회가 실패하면 기다리지 않는다 —
* 조회가 막혔다고 사용자를 준비 화면에 가두면 안 된다. */
const DESIGNING_POLL_MS = 3000;
const DESIGNING_MAX_WAIT_MS = 20 * 60 * 1000;
async function waitWhileDesigning(
projectId: string,
onWait: (label: string) => void,
): Promise<void> {
const deadline = Date.now() + DESIGNING_MAX_WAIT_MS;
while (Date.now() < deadline) {
let status;
try {
status = await fetchSurfaceStatus(projectId);
} catch {
return;
}
if (status.current_stage !== "initial_design") return;
onWait(status.message || "초기 설계를 계산하는 중…");
await new Promise((resolve) => window.setTimeout(resolve, DESIGNING_POLL_MS));
}
}
export async function renderB11Loading(root: HTMLElement): Promise<void> { export async function renderB11Loading(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath; const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath;
@@ -56,6 +80,9 @@ export async function renderB11Loading(root: HTMLElement): Promise<void> {
} }
try { try {
// 초기 설계 체인이 도는 동안은 여기서 기다린다(2026-08-29 사용자 확정) — 자료를 담아도
// 계산이 안 끝난 화면으로 보내면 반쯤 된 값을 만지게 된다.
await waitWhileDesigning(projectId, (label) => progress.set(0, label));
// 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다. // 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다.
await purgeOtherProjects(projectId); await purgeOtherProjects(projectId);
const signature = await preloadSurfaceAssets(projectId, (label, ratio) => { const signature = await preloadSurfaceAssets(projectId, (label, ratio) => {
@@ -19,6 +19,8 @@ import aiomysql
# 스냅샷 폴더는 워크플로우 단계가 아니므로 PROJECT_STORAGE_LAYOUT_V2에 넣지 않는다. # 스냅샷 폴더는 워크플로우 단계가 아니므로 PROJECT_STORAGE_LAYOUT_V2에 넣지 않는다.
SNAPSHOT_DIRNAME = "initial_snapshot" SNAPSHOT_DIRNAME = "initial_snapshot"
_DB_DUMP_NAME = "db.json" _DB_DUMP_NAME = "db.json"
# 초기 설계 체인이 도는 동안만 존재하는 마커(진입 차단 판정용).
DESIGNING_LOCK_NAME = "initial_design.lock"
# 프로젝트 루트 기준 상대 경로 — 정본 파일이 사는 자리 전부. # 프로젝트 루트 기준 상대 경로 — 정본 파일이 사는 자리 전부.
_FILE_TREES = ( _FILE_TREES = (
@@ -36,6 +38,51 @@ def snapshot_dir(project_root: Path) -> Path:
return Path(project_root) / SNAPSHOT_DIRNAME return Path(project_root) / SNAPSHOT_DIRNAME
def designing_lock_path(project_root: Path) -> Path:
"""초기 설계 체인이 도는 동안만 존재하는 마커.
이게 있으면 B05·B06은 아직 들어갈 때가 아니다(CLAUDE.md 5장 — 계산 중 편집이 섞이면
초기값이 오염된다). 스냅샷 대상 4트리 **밖**인 프로젝트 루트에 두어 복원에 딸려
들어가지 않게 한다. `workflow_state`의 stage 2는 체인이 끝나도 IN_PROGRESS라 판정에
못 쓰고, route 존재 여부도 체인 3단계에서 이미 생겨 못 쓴다.
"""
return Path(project_root) / DESIGNING_LOCK_NAME
def is_designing(project_root: Path) -> bool:
return designing_lock_path(project_root).is_file()
def mark_designing(project_root: Path) -> None:
path = designing_lock_path(project_root)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("designing", encoding="utf-8")
except OSError:
pass # 마커 실패가 체인을 막지는 않는다 — 문이 일찍 열릴 뿐이다.
def clear_designing(project_root: Path) -> None:
try:
designing_lock_path(project_root).unlink(missing_ok=True)
except OSError:
pass
def discard_initial_snapshot(project_root: Path) -> bool:
"""초기값을 무효화한다 — 지표면·노선이 바뀌어 옛 초기값이 더는 기준이 아닐 때.
그 자리에서 다시 뜨지 않는다. 재설계 체인은 **사용자 입력을 유지한 채** 재계산하므로
그 결과를 찍으면 사용자 편집이 섞인 가짜 초기값이 된다. 지워 두면 다음 [초기화]가
재계산 폴백을 타면서 새 초기값을 만들고, 체인이 그때 촬영한다(2026-08-29 사용자 확정).
"""
target = snapshot_dir(Path(project_root))
if not target.exists():
return False
shutil.rmtree(target, ignore_errors=True)
return True
def has_initial_snapshot(project_root: Path) -> bool: def has_initial_snapshot(project_root: Path) -> bool:
return (snapshot_dir(project_root) / _DB_DUMP_NAME).is_file() return (snapshot_dir(project_root) / _DB_DUMP_NAME).is_file()
@@ -93,6 +140,31 @@ async def save_initial_snapshot(
(target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") (target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
def wipe_edited_masters(project_root: Path) -> list[str]:
"""사용자 편집 정본을 걷어낸다 — 재계산으로 **진짜 초기값**을 만들기 위한 사전 정리.
자동설계 체인은 계획노선 CSV와 config 기본값으로 도는 결정적 절차라, 이 둘만 없으면
원래 나왔어야 할 값이 나온다. 남겨 두면 `resolve_extra_stations`가 사용자가 편집한
관·구조물에서 측점을 다시 파생해 "초기값"이 오염된다(2026-08-29 실측에서 확인).
스냅샷 복원 경로에서는 부르지 않는다 — 거기서는 스냅샷본이 통째로 덮어쓴다.
"""
removed: list[str] = []
root = Path(project_root)
for rel in ("B05_Profile/route/structures.json", "B04_PreProcess/drainage/edits"):
path = root / rel
try:
if path.is_dir():
shutil.rmtree(path)
removed.append(rel)
elif path.is_file():
path.unlink()
removed.append(rel)
except OSError:
pass # 지우지 못하면 그만큼 초기값이 덜 깨끗할 뿐, 재계산은 계속한다.
return removed
def restore_snapshot_files(project_root: Path) -> None: def restore_snapshot_files(project_root: Path) -> None:
"""스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다.""" """스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다."""
root = Path(project_root) root = Path(project_root)