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:
@@ -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),
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 사용자 지적).
|
||||
|
||||
@@ -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,
|
||||
/** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */
|
||||
|
||||
@@ -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 재계산을 부르는 무거운 작업이라 가끔만 쓴다
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user