feat(B05,B06): 자동저장을 걷어내고 초기값 스냅샷으로 초기화한다

CLAUDE.md 5장(조작·데이터 흐름 정책)을 코드에 반영한다. 조작분은 세션에만 쌓고
영구저장은 [저장]·[확정]에서만 하며, [초기화]는 재계산이 아니라 초기값 복원이다.

자동저장 폐지
- B06 조정창 기준벽 구간값: 800ms 디바운스 PUT을 없애고 세션(b06:culvertopt)에
  담는다. flushCulvertOptions()를 B06 [저장]·[확정]과 B05 [저장]이 부른다.
- B05 구조물: 조작 즉시 PUT + 서버 재조회로 화면을 덮어쓰던 것을 세션
  (b05:structures) 적재로 바꾼다. 저장 전에도 고르고 지울 수 있도록 식별자를
  crypto.randomUUID()로 미리 발급한다(서버는 빈 값일 때만 새로 발급).
- B05에서 만지고 B06에서 확정하는 경로를 위해 flushPendingStructures()를 공용화.

초기값 스냅샷
- common_util_initial_snapshot: 자동설계 체인 성공 직후 정본 파일 4트리와
  routes+자식 4표를 initial_snapshot/에 뜬다. 이후 읽기 전용.
- reset_route_design: 스냅샷이 있으면 DELETE와 같은 트랜잭션에서 행을 되세우고
  파일을 되돌린다. 없으면 종전 재계산 폴백. 응답에 restored를 더한다.

조작 응답
- 등고선 재적용·B06 진입 정합·[모두 적용]에서 전체 화면 오버레이 제거.
- 재계산이 design을 갈아끼울 때 extra_spans를 보존한다(다른 조작값과 동일).

테스트: tmp/tests/test_initial_snapshot.py 5건 추가. 245 passed·8 failed(기존
실패 — 기슭막이 이관 때 placement가 interval→point로 바뀐 것을 테스트 미반영).
This commit is contained in:
2026-08-29 11:15:29 +09:00
parent 213737289e
commit 08c0dc3228
11 changed files with 398 additions and 93 deletions
@@ -143,6 +143,7 @@ async def run_auto_design_chain(
from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route
from B05_Profile.B05_Profile_Schema import RoutePoint, RouteSolveRequest
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_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
from config.config_db import get_db_pool
@@ -231,6 +232,15 @@ async def run_auto_design_chain(
project_id,
route_id,
)
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
# 체인 규약대로 실패는 비치명적이다: 스냅샷이 없으면 [초기화]가 재계산으로 돈다.
try:
async with pool.acquire() as connection:
await save_initial_snapshot(connection, project_root, route_id)
except Exception: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
return {
"route_id": route_id,
"length_m": float(solve_result.total_length_m or 0.0),
+4 -2
View File
@@ -267,10 +267,12 @@ export interface RouteResetResponse {
project_id: string;
route_id: number;
deleted_routes: number;
/** 초기값 스냅샷을 되돌렸으면 true. false면 스냅샷이 없어 재계산으로 폴백한 것이다. */
restored?: boolean;
}
/** [초기화] — 사용자 편집을 전부 버리고 계획노선 CSV 기본값으로 B05·B06을 재계산한다.
* 경로 재탐색을 포함하므로 분석용 타임아웃을 쓴다. */
/** [초기화] — 사용자 편집을 전부 버리고 초기값으로 되돌린다. 초기값 스냅샷이 있으면
* 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다. */
export async function resetRouteDesign(projectId: string): Promise<RouteResetResponse> {
return requestJson<RouteResetResponse>(
`/projects/${projectId}/route/reset`,
+39
View File
@@ -187,3 +187,42 @@ export function defaultOptions(type: StructureType): Record<string, string | num
});
return options;
}
/* ── 미저장 구조물 조작분(세션) ────────────────────────────────────────────
* 조작은 세션에만 쌓고 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장,
* 2026-08-29 사용자 확정). B05에서 만지고 B06으로 넘어가 확정하는 경로가 있어
* 읽기·쓰기·내보내기를 여기 한 곳에 둔다. */
const pendingStructuresKey = (projectId: string): string => `b05:structures:${projectId}`;
/** 미저장 조작분. 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
export function readPendingStructures(projectId: string): StructureInstance[] | null {
try {
const raw = window.sessionStorage.getItem(pendingStructuresKey(projectId));
return raw ? (JSON.parse(raw) as StructureInstance[]) : null;
} catch {
return null;
}
}
export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void {
try {
const key = pendingStructuresKey(projectId);
if (next) window.sessionStorage.setItem(key, JSON.stringify(next));
else window.sessionStorage.removeItem(key);
} catch {
/* 세션 저장 실패는 무시 — 값은 화면에 남아 있다. */
}
}
/**
* 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다.
*/
export async function flushPendingStructures(projectId: string): Promise<void> {
const pending = readPendingStructures(projectId);
if (!pending) return;
const stored = await fetchStructures(projectId);
await saveStructures(projectId, stored.revision, pending);
writePendingStructures(projectId, null);
}
+38 -12
View File
@@ -607,21 +607,35 @@ async def confirm_latest_route(
@router.post("/{project_id}/route/reset")
async def reset_route_design(project_id: UUID) -> JSONResponse:
"""B05·B06 설계를 초기 자동 계산 상태로 되돌린다 ([초기화] 버튼, 2026-08-08 재정의).
"""B05·B06 설계를 초기값으로 되돌린다 ([초기화] 버튼).
사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)을 전부 버리고, 계획노선 CSV와
config 기본값으로 자동 설계 체인을 다시 돌려 파일입력 직후와 같은 상태를 만든다.
기존 경로 행을 지워야 체인의 수동 이력 보호 가드를 통과하며, 파생 데이터
(route_points·종횡단·설계 지정)는 FK CASCADE와 재계산이 정리한다. 재계산 뒤
stage 2·3은 IN_PROGRESS(검토 대기)가 된다.
**초기값 스냅샷이 있으면 복원한다**(2026-08-29 사용자 확정, CLAUDE.md 5장). 자동설계
체인 직후 떠 둔 `initial_snapshot/`의 DB 덤프와 정본 파일을 그대로 되돌려 놓는다 —
재계산이 아니다. 재계산으로는 초기값이 나오지 않는다: `structures.json`과
`edits/pipe_points.json`이 사용자 편집분인 채로 남아 구조물 측점이 그것에서 다시
파생되기 때문이다.
스냅샷이 없는 옛 프로젝트는 종전대로 자동 설계 체인을 다시 돌린다. 어느 경로든
사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)은 전부 버려지고, stage 2·3은
IN_PROGRESS(검토 대기)가 된다.
"""
from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
from B05_Profile.B05_Profile_Router_Corridor import prune_corridor_files
from common_util.common_util_initial_snapshot import (
has_initial_snapshot,
restore_initial_snapshot,
restore_snapshot_files,
)
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path)) if stored_path else None
restored = bool(project_root and has_initial_snapshot(project_root))
async with pool.acquire() as connection:
# 확정 지표면 모델을 초기 체인과 같은 기준(config 기본값)으로 다시 찾는다.
try:
@@ -637,29 +651,40 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
"DELETE FROM routes WHERE project_id = %s", (str(project_id),)
)
deleted = cursor.rowcount
# 복원은 같은 트랜잭션 안에서 끝낸다 — 지우기만 하고 실패하면 경로가 없다.
if restored and project_root:
await restore_initial_snapshot(connection, project_root, str(project_id))
await connection.commit()
except Exception:
await connection.rollback()
raise
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
if restored and project_root:
# 정본 파일도 스냅샷본으로 되돌린다 — 이것을 빼면 구조물·관 편집분이 남아
# 초기값이 오염된다(2026-08-29).
await asyncio.to_thread(restore_snapshot_files, project_root)
else:
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
async with pool.acquire() as connection:
latest = await get_latest_route(connection, project_id)
if not latest:
return JSONResponse(
status_code=500,
content={"status": "error", "message": "초기 경로 재계산에 실패했습니다."},
content={
"status": "error",
"message": "초기값 복원에 실패했습니다."
if restored
else "초기 경로 재계산에 실패했습니다.",
},
)
# 옛 경로의 코리도 파일은 주인이 사라졌다 — 함께 지운다(2026-08-28 백로그).
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
if stored_path:
if project_root:
removed = await asyncio.to_thread(
prune_corridor_files,
Path(resolve_stored_project_path(stored_path)),
project_root,
{int(latest["id"])},
)
if removed:
@@ -676,6 +701,7 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
"project_id": str(project_id),
"route_id": latest["id"],
"deleted_routes": deleted,
"restored": restored,
}
)
except Exception:
+13 -4
View File
@@ -43,6 +43,7 @@ import {
fetchSectionContext,
type SectionDetailResponse,
} from "../B06_Section/B06_Section_Api_Fetch";
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
import {
invalidateSectionDetail,
loadSectionDetail,
@@ -266,7 +267,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
navigateTo(ROUTES.B06_SECTION);
},
onReset: () => void resetDesign(),
onContourApply: (interval) => void applyContours(interval),
onContourApply: (interval) => applyContours(interval),
onSurfaceVisible: viewer.setSurfaceVisible,
onCorridorVisible: (visible) => {
corridorVisible = visible;
@@ -541,7 +542,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
}
async function applyContours(interval: number): Promise<void> {
showLoadingOverlay();
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장).
try {
await viewer.reloadContours(interval);
await updateContourInterval(activeProjectId, interval);
@@ -555,8 +556,6 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
}
} catch (error) {
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
} finally {
hideLoadingOverlay();
}
}
@@ -630,6 +629,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
try {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다.
await profilePanel.save();
// 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장).
await bridge.saveStructuresIfDirty();
// 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
await confirmRoute(
activeProjectId,
@@ -650,6 +651,14 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
},
false,
);
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
if (latest?.route?.id != null) {
await flushCulvertOptions(
activeProjectId,
`b06:culvertopt:${activeProjectId}:${latest.route.id}`,
).catch(() => undefined);
}
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
// 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가
// 최종본으로 얹힌다(2026-08-24 사용자 지적).
+38 -4
View File
@@ -19,6 +19,8 @@ import {
fetchStructures,
fetchStructureTypes,
saveStructures,
readPendingStructures,
writePendingStructures,
StructureConflictError,
type StructureInstance,
} from "./B05_Profile_Api_Structures";
@@ -193,18 +195,46 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
}));
}
/* ── 미저장 조작분 세션 보관(2026-08-29 — CLAUDE.md 5장) ──────────────
* 조작은 화면과 세션에만 남기고 영구저장은 [저장]·[확정]에서만 한다. 세션에 두는
* 이유는 B06을 다녀오면 B05가 다시 그려져 메모리 값이 사라지기 때문이다. */
const readPending = (): StructureInstance[] | null => readPendingStructures(deps.projectId);
const writePending = (next: StructureInstance[] | null): void =>
writePendingStructures(deps.projectId, next);
/** 새 항목에 식별자를 미리 붙인다. 저장 전에도 목록에서 고르고 지울 수 있어야 하고
* (식별자가 null이면 여러 건이 서로 구분되지 않는다), 서버는 빈 값일 때만 새로
* 발급하므로 이 값이 그대로 정본 식별자가 된다(`_Structures_Repository.py:118`). */
function withLocalIds(next: StructureInstance[]): StructureInstance[] {
return next.map((item) =>
item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") },
);
}
function applyStructures(next: StructureInstance[]): void {
ownStructures = next;
ownStructures = withLocalIds(next);
syncGraphStructures();
syncCrossDrainStations();
// 복원 중 호출은 사용자 조작이 아니다 — 미저장 표시를 남기지 않는다.
if (!deps.isRestoring()) {
deps.panel().structures.setStructures(ownStructures);
writePending(ownStructures);
}
}
/** [저장]·[확정]에서만 부른다 — 미저장분이 있으면 정본에 한 번에 쓴다. */
async function saveStructuresIfDirty(): Promise<void> {
if (readPending() === null) return;
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
structureSaving = structureSaving.then(() => persistStructures(next));
structureSaving = structureSaving.then(() => persistStructures(ownStructures));
await structureSaving;
}
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
async function refreshStructuresFromServer(): Promise<boolean> {
const stored = await fetchStructures(deps.projectId).catch(() => null);
if (!stored) return false;
writePending(null);
structureRevision = stored.revision;
deps.panel().structures.setStructures(stored.structures);
ownStructures = stored.structures;
@@ -218,6 +248,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
try {
const saved = await saveStructures(deps.projectId, structureRevision, next);
structureRevision = saved.revision;
writePending(null);
// 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다.
await refreshStructuresFromServer();
// 구조물이 바뀌면 B06 이후를 다시 돌려야 한다. 그 표시를 서버가 못 남겼다면
@@ -256,8 +287,10 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
types.map((type) => [type.type_id, { group: type.group, name: type.name }]),
);
structureRevision = stored.revision;
deps.panel().structures.setStructures(stored.structures);
ownStructures = stored.structures;
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
const pending = readPending();
ownStructures = pending ?? stored.structures;
deps.panel().structures.setStructures(ownStructures);
syncGraphStructures();
syncCrossDrainStations();
} catch (error) {
@@ -302,6 +335,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
pipeMarkChainage,
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
applyStructures,
saveStructuresIfDirty,
/** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */
refreshFromServer: refreshStructuresFromServer,
/** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */
+14 -5
View File
@@ -89,7 +89,7 @@ interface PanelCallbacks {
onGoCross: () => void;
/** [초기화] — 사용자 편집을 버리고 초기 자동 계산 상태로 롤백. */
onReset: () => void;
onContourApply: (interval: number) => void;
onContourApply: (interval: number) => void | Promise<void>;
onSurfaceVisible: (visible: boolean) => void;
/** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */
onCorridorVisible: (visible: boolean) => void;
@@ -241,10 +241,19 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const contourInterval = numberField("간격 (m), 최소 0.5m", "1");
const contourRow = document.createElement("div");
contourRow.className = "b05-route__contour-row";
contourRow.append(
contourInterval.wrapper,
button("재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)),
);
// 등고선 재적용은 화면 전체를 잠그지 않는다(CLAUDE.md 5장) — 누른 버튼만 잠가
// 진행을 알리고, 나머지 조작은 그대로 열어 둔다.
const contourApply = button("재적용", () => {
contourApply.disabled = true;
contourApply.textContent = "적용 중…";
void Promise.resolve(callbacks.onContourApply(Number(contourInterval.value) || 1)).finally(
() => {
contourApply.disabled = false;
contourApply.textContent = "재적용";
},
);
});
contourRow.append(contourInterval.wrapper, contourApply);
// 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에
// 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다
+69 -59
View File
@@ -6,8 +6,10 @@
* 값을 B06 정본(design)에 따로 담지 않는 이유: 같은 숫자가 B05 배수관 카드에도
* 있어서 두 곳이 갈리면 어느 쪽이 참인지 알 수 없다. 원천은 하나여야 한다.
*
* 저장은 **묶어서 늦게** 한다 — +/- 한 번마다 PUT하면 세부유역 재계산이 매번
* 돌아간다. 화면은 캐시를 고쳐 즉시 반영하고, 저장은 마지막 조작 뒤 한 번만 간다.
* 조작분은 **세션에만** 쌓고 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장
* 조작·데이터 흐름 정책, 2026-08-29 사용자 확정). 종전 800ms 디바운스 자동 PUT은
* 폐지했다 — 저장을 누르지 않았는데 영구저장소가 바뀌면 [초기화]의 기준선이 흐려진다.
* 세션에 담는 이유는 B06에서 만진 값을 B05 [저장]으로도 내보내야 하기 때문이다.
* ========================================================================== */
import {
@@ -16,83 +18,91 @@ import {
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
/** 마지막 조작 뒤 이만큼 조용하면 저장한다(ms). */
const SAVE_DELAY_MS = 800;
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로.
* 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */
export type CulvertOptionPatch = Record<string, number | string>;
/** 세션에 쌓인 조각 — 측점(누가거리 2자리 문자열) → 옵션 조각. */
type PendingMap = Record<string, CulvertOptionPatch>;
export interface CulvertOptionWriter {
/** 측점 옵션을 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
/** 측점 옵션을 세션에 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
queue: (chainageM: number, patch: CulvertOptionPatch) => void;
/** 예약분을 즉시 내보낸다(확정·임시저장 직전에 쓴다). */
/** 예약분을 즉시 내보낸다([저장]·[확정]에서만 부른다). */
flush: () => Promise<void>;
}
const keyOf = (chainageM: number): string => chainageM.toFixed(2);
function readPending(sessionKey: string | null): PendingMap {
if (!sessionKey) return {};
try {
const raw = window.sessionStorage.getItem(sessionKey);
return raw ? (JSON.parse(raw) as PendingMap) : {};
} catch {
return {};
}
}
function writePending(sessionKey: string | null, pending: PendingMap): void {
if (!sessionKey) return;
try {
if (Object.keys(pending).length === 0) window.sessionStorage.removeItem(sessionKey);
else window.sessionStorage.setItem(sessionKey, JSON.stringify(pending));
} catch {
/* 세션 저장 실패는 무시 — 값은 화면 캐시에 남아 있다. */
}
}
/**
* 저장기 하나를 만든다. `projectId`가 없으면 아무것도 하지 않는다(조회 전용 상태).
* `onError`는 저장 실패를 화면에 알리는 쪽이 구현한다.
* 세션에 쌓인 구간값을 `pipe_points.json`에 한 번에 쓴다. 예약분이 없으면 아무것도
* 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서
* 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다.
*/
export async function flushCulvertOptions(
projectId: string | null,
sessionKey: string | null,
): Promise<void> {
const pending = readPending(sessionKey);
const entries = Object.entries(pending);
if (!projectId || entries.length === 0) return;
const current = await fetchDetailPipePoints(projectId);
let touched = false;
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
const { lonlat: _lonlat, ...input } = point;
const patch = pending[keyOf(point.chainage_m)];
if (!patch) return input;
touched = true;
return { ...input, options: { ...(input.options ?? {}), ...patch } };
});
if (touched) await saveDetailPipePoints(projectId, points);
writePending(sessionKey, {});
}
/**
* 저장기 하나를 만든다. `projectId`·`sessionKey`가 없으면 예약만 메모리에 남는다
* (조회 전용 상태). `onError`는 [저장] 시점 실패를 화면에 알리는 쪽이 구현한다.
*/
export function createCulvertOptionWriter(
projectId: () => string | null,
sessionKey: () => string | null,
onError?: (message: string) => void,
): CulvertOptionWriter {
/** 측점(누가거리 문자열) → 아직 안 보낸 옵션 조각. */
const pending = new Map<string, CulvertOptionPatch>();
let timer: ReturnType<typeof setTimeout> | null = null;
let inFlight: Promise<void> = Promise.resolve();
const keyOf = (chainageM: number): string => chainageM.toFixed(2);
async function send(): Promise<void> {
const id = projectId();
if (!id || pending.size === 0) {
pending.clear();
return;
}
const batch = new Map(pending);
pending.clear();
try {
const current = await fetchDetailPipePoints(id);
let touched = false;
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
const { lonlat: _lonlat, ...input } = point;
const patch = batch.get(keyOf(point.chainage_m));
if (!patch) return input;
touched = true;
return { ...input, options: { ...(input.options ?? {}), ...patch } };
});
if (!touched) return;
await saveDetailPipePoints(id, points);
} catch (error) {
// 실패한 조각은 되살려 다음 기회에 다시 시도한다 — 조용히 잃으면 화면과
// 저장분이 갈린다.
batch.forEach((patch, key) => {
pending.set(key, { ...patch, ...(pending.get(key) ?? {}) });
});
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
}
}
return {
queue(chainageM, patch) {
const key = keyOf(chainageM);
pending.set(key, { ...(pending.get(key) ?? {}), ...patch });
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
inFlight = inFlight.then(send);
}, SAVE_DELAY_MS);
const key = sessionKey();
const pending = readPending(key);
pending[keyOf(chainageM)] = { ...(pending[keyOf(chainageM)] ?? {}), ...patch };
writePending(key, pending);
},
async flush() {
if (timer) {
clearTimeout(timer);
timer = null;
try {
await flushCulvertOptions(projectId(), sessionKey());
} catch (error) {
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
throw error;
}
inFlight = inFlight.then(send);
await inFlight;
},
};
}
+21 -6
View File
@@ -29,6 +29,7 @@ import {
type SectionDetailResponse,
type StandardCrossSection,
} from "./B06_Section_Api_Fetch";
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
import { buildCrossPatches } from "./B06_Section_UI_Page_Patches";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
@@ -170,6 +171,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
basin_adjust: target.design?.basin_adjust,
revet_adjust: target.design?.revet_adjust,
extra_wall_counts: target.design?.extra_wall_counts,
extra_spans: target.design?.extra_spans,
revet_link_detached: target.design?.revet_link_detached,
revet_follow_grade: target.design?.revet_follow_grade,
};
@@ -230,7 +232,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
return staleByPlan.has(section.chainage_m);
});
if (!stale.length) return;
showLoadingOverlay();
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
try {
const alignment = sectionDetail.longitudinal.profile_alignment as
| {
@@ -264,6 +267,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
basin_adjust: section.design?.basin_adjust,
revet_adjust: section.design?.revet_adjust,
extra_wall_counts: section.design?.extra_wall_counts,
extra_spans: section.design?.extra_spans,
revet_link_detached: section.design?.revet_link_detached,
revet_follow_grade: section.design?.revet_follow_grade,
};
@@ -273,8 +277,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
@@ -313,11 +315,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
let applyingAll = false;
async function applyPanelToAll(): Promise<void> {
if (!sectionDetail || !projectId || currentRouteId === null) return;
if (applyingAll || !sectionDetail || !projectId || currentRouteId === null) return;
const targets = sectionDetail.cross_sections.filter((section) => section.design);
if (!targets.length) return;
showLoadingOverlay();
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
// 대신 도는 동안 다시 누르는 것만 막는다.
applyingAll = true;
try {
for (const section of targets) {
const change = changeFromDesign(section.chainage_m);
@@ -325,7 +330,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
showToast(L("B06_Std_ApplyAll_Success"), "success");
} finally {
hideLoadingOverlay();
applyingAll = false;
}
}
@@ -547,6 +552,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!projectId || currentRouteId === null) return;
showLoadingOverlay();
try {
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
await stationControls.flushCulvertOptions();
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다.
await flushPendingStructures(projectId);
const edits = collectSectionEdits();
await saveSections(
projectId,
@@ -568,6 +578,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!projectId || currentRouteId === null) return;
showLoadingOverlay();
try {
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
await stationControls.flushCulvertOptions();
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다.
await flushPendingStructures(projectId);
const edits = collectSectionEdits();
await confirmSections(
projectId,
@@ -43,6 +43,7 @@ export interface StationControlDeps {
| "basinadjust"
| "extrawall"
| "extraspan"
| "culvertopt"
| "revetlink"
| "fordadjust"
| "boxadjust",
@@ -501,7 +502,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
* 캐시(detail의 culvert 스펙)를 고쳐 즉시 보여주고, 저장은 페이지가 예약해 늦게
* 한 번 내보낸다. 링크 측점에서 만져도 바뀌는 것은 **소유 측점** 값이다.
* 연동은 측점별(세션 → 정본 → 기본 켬), 종단경사 반영은 소유 측점에 하나. */
const culvertOptions = createCulvertOptionWriter(deps.projectId, deps.onSaveError);
const culvertOptions = createCulvertOptionWriter(
deps.projectId,
() => deps.sessionKey("culvertopt"),
deps.onSaveError,
);
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
const linkDetached = linkSession.detached;
const followGrades = linkSession.followGrade;
+146
View File
@@ -0,0 +1,146 @@
"""초기값 스냅샷 — 자동설계 체인이 만든 첫 결과를 그대로 떠 두고 [초기화]가 되돌린다.
CLAUDE.md 5장(조작·데이터 흐름 정책)의 **초기값** 층이다. 자동설계 체인이 끝난 직후
한 번 찍고, 그 뒤로는 읽기 전용이다 — 어떤 저장 경로도 이 폴더에 쓰지 않는다.
[초기화]가 재계산이 아니라 복원이어야 하는 이유: [저장]·[확정]이 새 행을 만들지 않고
최신 `routes` 행을 제자리 갱신하고 종·횡단 정본 파일도 덮어쓰므로, 초기 상태는 따로
떠 두지 않으면 남지 않는다. 재계산으로 되살리려 해도 `structures.json`·
`pipe_points.json` 편집분이 그대로 남아 초기값과 다른 결과가 나온다(2026-08-29).
"""
import json
import shutil
from pathlib import Path
from typing import Any
import aiomysql
# 스냅샷 폴더는 워크플로우 단계가 아니므로 PROJECT_STORAGE_LAYOUT_V2에 넣지 않는다.
SNAPSHOT_DIRNAME = "initial_snapshot"
_DB_DUMP_NAME = "db.json"
# 프로젝트 루트 기준 상대 경로 — 정본 파일이 사는 자리 전부.
_FILE_TREES = (
"B05_Profile/route",
"B06_Section/longitudinal",
"B06_Section/cross_sections",
"B04_PreProcess/drainage/edits",
)
# `routes.id`를 참조하는 표는 스키마상 이 넷이 전부다(001_create_schema.sql:539~556).
_CHILD_TABLES = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections")
def snapshot_dir(project_root: Path) -> Path:
return Path(project_root) / SNAPSHOT_DIRNAME
def has_initial_snapshot(project_root: Path) -> bool:
return (snapshot_dir(project_root) / _DB_DUMP_NAME).is_file()
def _copy_tree(source: Path, target: Path) -> None:
if not source.is_dir():
return
if target.exists():
shutil.rmtree(target)
shutil.copytree(source, target)
async def _dump_rows(
connection: aiomysql.Connection, table: str, route_id: int
) -> list[dict[str, Any]]:
async with connection.cursor(aiomysql.DictCursor) as cursor:
# 표 이름은 이 모듈의 상수에서만 오므로 자리표시자 대상이 아니다.
await cursor.execute(f"SELECT * FROM {table} WHERE route_id = %s", (route_id,)) # noqa: S608
return list(await cursor.fetchall())
def _json_safe(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""TIMESTAMP 등 JSON이 모르는 값을 문자열로 낮춘다."""
return [
{key: (value if _is_json_native(value) else str(value)) for key, value in row.items()}
for row in rows
]
def _is_json_native(value: Any) -> bool:
return value is None or isinstance(value, (bool, int, float, str, list, dict))
async def save_initial_snapshot(
connection: aiomysql.Connection, project_root: Path, route_id: int
) -> None:
"""자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다."""
root = Path(project_root)
target = snapshot_dir(root)
if has_initial_snapshot(root):
return
target.mkdir(parents=True, exist_ok=True)
for tree in _FILE_TREES:
_copy_tree(root / tree, target / tree.replace("/", "__"))
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute("SELECT * FROM routes WHERE id = %s", (route_id,))
route = await cursor.fetchone()
if not route:
return
dump: dict[str, Any] = {"routes": _json_safe([dict(route)])}
for table in _CHILD_TABLES:
dump[table] = _json_safe(await _dump_rows(connection, table, route_id))
(target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
def restore_snapshot_files(project_root: Path) -> None:
"""스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다."""
root = Path(project_root)
source = snapshot_dir(root)
for tree in _FILE_TREES:
_copy_tree(source / tree.replace("/", "__"), root / tree)
async def restore_initial_snapshot(
connection: aiomysql.Connection, project_root: Path, project_id: str
) -> int | None:
"""`routes`와 자식 4표를 스냅샷 값으로 다시 세우고 새 route id를 돌려준다.
호출자가 기존 `routes` 행을 지운 **뒤에** 부른다(자식은 FK CASCADE로 함께 지워진다).
파일 복원은 트랜잭션 밖이라 `restore_snapshot_files()`를 따로 부른다.
"""
dump_path = snapshot_dir(Path(project_root)) / _DB_DUMP_NAME
if not dump_path.is_file():
return None
dump = json.loads(dump_path.read_text(encoding="utf-8"))
route = (dump.get("routes") or [None])[0]
if not route:
return None
route = dict(route)
route.pop("id", None)
route["project_id"] = project_id
new_id = await _insert_row(connection, "routes", route)
for table in _CHILD_TABLES:
for row in dump.get(table, []):
child = dict(row)
child.pop("id", None)
child["route_id"] = new_id
if "project_id" in child:
child["project_id"] = project_id
await _insert_row(connection, table, child)
return new_id
async def _insert_row(connection: aiomysql.Connection, table: str, row: dict[str, Any]) -> int:
columns = list(row.keys())
placeholders = ", ".join(["%s"] * len(columns))
names = ", ".join(f"`{name}`" for name in columns)
async with connection.cursor() as cursor:
# 표 이름은 상수, 열 이름은 스냅샷이 뜬 실제 스키마에서 온다.
await cursor.execute(
f"INSERT INTO {table} ({names}) VALUES ({placeholders})", # noqa: S608
tuple(row[name] for name in columns),
)
return int(cursor.lastrowid)