fix(B05 초기화): 빈틈 넷 — 폴백 덧 측점 지움 · 배수 폴더 없는 스냅샷 관 편집 비움 · 복원 길 stage 2 · 3 검토 대기 + B07 이후 STALE · M02 구조물 사본 맞춤 (431)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
2026-10-18 15:37:02 +09:00
co-authored by Claude Opus 5.5
parent 5c5d318508
commit c87fa54f5b
3 changed files with 172 additions and 5 deletions
+18 -3
View File
@@ -8,9 +8,10 @@
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -23,8 +24,9 @@ from B05_Profile.B05_Profile_Router_Confirm import (
)
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteConfirmResponse
from B06_Section.B06_Section_Repository import get_longitudinal_section
from common_util.common_util_auth import get_optional_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import complete_stage
from common_util.common_util_workflow_state import complete_stage, start_stage
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
@@ -140,7 +142,10 @@ async def confirm_latest_route(
@router.post("/{project_id}/route/reset")
async def reset_route_design(project_id: UUID) -> JSONResponse:
async def reset_route_design(
project_id: UUID,
session: dict[str, Any] | None = Depends(get_optional_session), # 사본 가져온이
) -> JSONResponse:
"""B05·B06 설계를 초기값으로 되돌린다 ([초기화] 버튼).
**초기값 스냅샷이 있으면 복원한다**(2026-08-29 사용자 확정, CLAUDE.md 5장). 자동설계
@@ -224,6 +229,10 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
restored_route_id = await restore_initial_snapshot(
connection, project_root, str(project_id)
)
# 재계산 길(체인)과 같은 단계 — stage 2 · 3 검토 대기 · B07 이후 STALE
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 2)
await start_stage(cursor, str(project_id), 3)
await connection.commit()
except Exception:
await connection.rollback()
@@ -258,6 +267,12 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
},
)
# M02 구조물 사본을 되돌린 구조물 · 관 지정에 맞춤(실패해도 초기화 유지)
if project_root:
from M02_MasterTemplete.M02_Item_Snapshot import importer, safe_sync
await asyncio.to_thread(safe_sync, project_root, importer(session))
# 옛 경로의 코리도 파일은 주인이 사라졌다 — 함께 지운다(2026-08-28 백로그).
try:
if project_root:
+11 -2
View File
@@ -243,7 +243,11 @@ def wipe_edited_masters(project_root: Path) -> list[str]:
"""
removed: list[str] = []
root = Path(project_root)
for rel in ("B05_Profile/route/structures.json", "B04_PreProcess/drainage/edits"):
for rel in (
"B05_Profile/route/structures.json",
"B05_Profile/route/plus_stations.json",
"B04_PreProcess/drainage/edits",
):
path = root / rel
try:
if path.is_dir():
@@ -269,7 +273,12 @@ def restore_snapshot_files(project_root: Path, route_id: int | None = None) -> N
for tree in _FILE_TREES:
stored = source / tree.replace("/", "__")
if not stored.is_dir() and tree == "B04_PreProcess/drainage":
_copy_tree(source / _LEGACY_DRAINAGE_TREE, root / "B04_PreProcess/drainage/edits")
legacy = source / _LEGACY_DRAINAGE_TREE
edits = root / "B04_PreProcess/drainage/edits"
if legacy.is_dir():
_copy_tree(legacy, edits)
else: # 배수 폴더 없는 스냅샷 — 관 편집은 비움(초기값에 없던 것)
shutil.rmtree(edits, ignore_errors=True)
continue
_copy_tree(stored, root / tree)
+143
View File
@@ -0,0 +1,143 @@
"""431 [초기화] 빈틈(분석 424 3장).
- 스냅샷 없는 폴백 = 덧 측점(`plus_stations.json`)도 지움
- 배수 폴더 없는 스냅샷 = 관 편집 비움
- 복원 길 = stage 2 · 3 검토 대기 · B07 · B08 STALE · M02 구조물 사본 맞춤(진짜 `safe_sync`)
"""
import asyncio
import json
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
from common_util.common_util_initial_snapshot import (
restore_snapshot_files,
snapshot_dir,
wipe_edited_masters,
)
EDITS = "B04_PreProcess/drainage/edits"
def _touch(path: Path, text: str = "{}") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def test_폴백은_덧_측점도_지움(tmp_path: Path):
_touch(tmp_path / "B05_Profile/route/plus_stations.json")
_touch(tmp_path / "B05_Profile/route/structures.json")
_touch(tmp_path / EDITS / "pipe_points.json")
removed = wipe_edited_masters(tmp_path)
assert "B05_Profile/route/plus_stations.json" in removed
assert not (tmp_path / "B05_Profile/route/plus_stations.json").exists()
assert not (tmp_path / EDITS).exists()
def _snapshot_without_drainage(root: Path) -> None:
_touch(snapshot_dir(root) / "B05_Profile__route" / "planned_route.csv", "x,y\n")
_touch(snapshot_dir(root) / "db.json", json.dumps({"routes": [{"id": 1}]}))
def test_배수_폴더_없는_스냅샷은_관_편집_비움(tmp_path: Path):
_snapshot_without_drainage(tmp_path)
_touch(tmp_path / EDITS / "pipe_points.json", '[{"chainage_m": 3}]')
_touch(tmp_path / "B04_PreProcess/drainage/basins.json") # 분석 산출물은 그대로
restore_snapshot_files(tmp_path)
assert not (tmp_path / EDITS).exists()
assert (tmp_path / "B04_PreProcess/drainage/basins.json").is_file()
def test_옛_스냅샷은_edits_만_되돌림(tmp_path: Path):
_snapshot_without_drainage(tmp_path)
_touch(snapshot_dir(tmp_path) / "B04_PreProcess__drainage__edits/pipe_points.json", "[]")
_touch(tmp_path / EDITS / "pipe_points.json", '[{"chainage_m": 3}]')
restore_snapshot_files(tmp_path)
assert (tmp_path / EDITS / "pipe_points.json").read_text(encoding="utf-8") == "[]"
class _Cursor:
def __init__(self, log: list[tuple[str, tuple]]):
self._log = log
self.rowcount = 1
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql, params=None):
self._log.append((" ".join(sql.split()), params))
class _Connection:
def __init__(self, log):
self._log = log
def cursor(self, *_a, **_k):
return _Cursor(self._log)
async def begin(self):
pass
async def commit(self):
self._log.append(("COMMIT", ()))
async def rollback(self):
self._log.append(("ROLLBACK", ()))
class _Pool:
def __init__(self, log):
self._log = log
@asynccontextmanager
async def acquire(self):
yield _Connection(self._log)
def _async(value):
async def run(*_a, **_k):
return value
return run
def test_복원_길은_단계_되돌리고_사본_맞춤(tmp_path: Path, monkeypatch):
from B05_Profile import B05_Profile_Router_Corridor as corridor
from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle
from common_util import common_util_initial_snapshot as snapshot
_snapshot_without_drainage(tmp_path)
_touch(tmp_path / EDITS / "pipe_points.json", "[]")
# 사용자가 넣은 구조물의 사본 — 초기값엔 그 구조물 없음
_touch(tmp_path / "templates/structures/s9/manifest.json", '{"key": "SS9"}')
log: list[tuple[str, tuple]] = []
monkeypatch.setattr(lifecycle, "get_db_pool", lambda: _Pool(log))
monkeypatch.setattr(lifecycle, "get_project_storage_relative_path", _async("p"))
monkeypatch.setattr(lifecycle, "resolve_stored_project_path", lambda _p: str(tmp_path))
monkeypatch.setattr(lifecycle, "get_latest_route", _async({"id": 9}))
monkeypatch.setattr(snapshot, "restore_initial_snapshot", _async(9))
monkeypatch.setattr(corridor, "prune_corridor_files", lambda *_a: 0)
pid = uuid4()
response = asyncio.run(lifecycle.reset_route_design(pid, None))
body = json.loads(response.body)
assert body["restored"] is True and body["route_id"] == 9
started = [
p[3]
for sql, p in log
if sql.startswith("UPDATE project_workflow_stages SET state = 'IN_PROGRESS'")
]
assert started == [2, 3]
stale = [p[1] for sql, p in log if "SET state = 'STALE'" in sql]
assert stale == [2, 3] # stage > 3 (B07 · B08 · B09) 완료분 STALE
assert log.index(("COMMIT", ())) > max(
i for i, (sql, _) in enumerate(log) if "project_workflow_stages" in sql
)
assert not (tmp_path / EDITS).exists()
assert not (tmp_path / "templates/structures/s9").exists()