From 08c0dc32287a3fd118e5712cb44899b40f5bbda7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 11:15:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05,B06):=20=EC=9E=90=EB=8F=99=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B4=EA=B3=A0=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=EA=B0=92=20=EC=8A=A4=EB=83=85=EC=83=B7?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B4=88=EA=B8=B0=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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로 바뀐 것을 테스트 미반영). --- B03_FileInput/B03_FileInput_Service_Chain.py | 10 ++ B05_Profile/B05_Profile_Api_Fetch.ts | 6 +- B05_Profile/B05_Profile_Api_Structures.ts | 39 +++++ B05_Profile/B05_Profile_Router.py | 50 ++++-- B05_Profile/B05_Profile_UI_Page.ts | 17 +- B05_Profile/B05_Profile_UI_Page_Structures.ts | 42 ++++- B05_Profile/B05_Profile_UI_Panel.ts | 19 ++- .../B06_Section_Api_Culvert_Options.ts | 128 ++++++++------- B06_Section/B06_Section_UI_Page.ts | 27 +++- .../B06_Section_UI_Page_Station_Controls.ts | 7 +- common_util/common_util_initial_snapshot.py | 146 ++++++++++++++++++ 11 files changed, 398 insertions(+), 93 deletions(-) create mode 100644 common_util/common_util_initial_snapshot.py diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index ddc509a6..1381103d 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -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), diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index ada2b857..677be035 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -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 { return requestJson( `/projects/${projectId}/route/reset`, diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 6eebc22a..9c7a48a2 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -187,3 +187,42 @@ export function defaultOptions(type: StructureType): Record `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 { + const pending = readPendingStructures(projectId); + if (!pending) return; + const stored = await fetchStructures(projectId); + await saveStructures(projectId, stored.revision, pending); + writePendingStructures(projectId, null); +} diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index ff6f6c1c..30d1526e 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -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: diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 7cba503f..42ab91ad 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -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 { 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 { } async function applyContours(interval: number): Promise { - showLoadingOverlay(); + // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). try { await viewer.reloadContours(interval); await updateContourInterval(activeProjectId, interval); @@ -555,8 +556,6 @@ export async function renderB05Route(root: HTMLElement): Promise { } } catch (error) { showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); - } finally { - hideLoadingOverlay(); } } @@ -630,6 +629,8 @@ export async function renderB05Route(root: HTMLElement): Promise { 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 { }, 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 사용자 지적). diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 4a68b7a9..7402e314 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -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 { + if (readPending() === null) return; // 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다. - structureSaving = structureSaving.then(() => persistStructures(next)); + structureSaving = structureSaving.then(() => persistStructures(ownStructures)); + await structureSaving; } /** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */ async function refreshStructuresFromServer(): Promise { 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, /** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */ diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 80dff800..7dfe9199 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -89,7 +89,7 @@ interface PanelCallbacks { onGoCross: () => void; /** [초기화] — 사용자 편집을 버리고 초기 자동 계산 상태로 롤백. */ onReset: () => void; - onContourApply: (interval: number) => void; + onContourApply: (interval: number) => void | Promise; 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 재계산을 부르는 무거운 작업이라 가끔만 쓴다 diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 3b08d6cd..a62ed184 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -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; +/** 세션에 쌓인 조각 — 측점(누가거리 2자리 문자열) → 옵션 조각. */ +type PendingMap = Record; + export interface CulvertOptionWriter { - /** 측점 옵션을 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */ + /** 측점 옵션을 세션에 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */ queue: (chainageM: number, patch: CulvertOptionPatch) => void; - /** 예약분을 즉시 내보낸다(확정·임시저장 직전에 쓴다). */ + /** 예약분을 즉시 내보낸다([저장]·[확정]에서만 부른다). */ flush: () => Promise; } +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 { + 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(); - let timer: ReturnType | null = null; - let inFlight: Promise = Promise.resolve(); - - const keyOf = (chainageM: number): string => chainageM.toFixed(2); - - async function send(): Promise { - 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; }, }; } diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 24835730..a1276877 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -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 { 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 { 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 { 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 { } 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 { /** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다. * handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만 * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */ + let applyingAll = false; async function applyPanelToAll(): Promise { - 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 { } showToast(L("B06_Std_ApplyAll_Success"), "success"); } finally { - hideLoadingOverlay(); + applyingAll = false; } } @@ -547,6 +552,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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 { if (!projectId || currentRouteId === null) return; showLoadingOverlay(); try { + // 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 + // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). + await stationControls.flushCulvertOptions(); + // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. + await flushPendingStructures(projectId); const edits = collectSectionEdits(); await confirmSections( projectId, diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index dbb6f0dd..94a527ab 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -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; diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py new file mode 100644 index 00000000..201009b8 --- /dev/null +++ b/common_util/common_util_initial_snapshot.py @@ -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)