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
+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;