Files
Aislo/B06_Section/B06_Section_UI_Page_Persist.ts
T
eomsangdonandClaude Opus 5 9b4abecf9d refactor(B05/B06): 유토곡선 일원화 — 보는 노선·횡단 재계산 창구·낡음 판정 통합
같은 데이터를 두 화면에 보여 주는 기능인데 세 곳이 갈려 값이 달랐다(실측: 절토(자연)
B06 3,704.6㎥ ↔ B05 4,526.8㎥).

① 보는 노선 — B05는 최신 경로, B06은 최신 **확정** 경로를 열어 노선을 다시 탐색한
   프로젝트에서 서로 다른 노선을 봤다(route 126 DRAFT ↔ 125 CONFIRMED, 같은 20m
   측점 성토 4.82㎡ ↔ 85.9㎡). `get_workflow_route_context()`(최신 경로) 신설해 B06
   화면 context가 그것을 쓴다. 납품 도면(B07)이 쓰는 확정 전용 창구는 그대로 둔다.

② 횡단 재계산 — 호출이 두 벌이라 인자가 갈렸다(B06만 표준 단면값·암 경계 오프셋 전달,
   보존하는 사용자 부속값도 2개 ↔ 7개). `B06_Section_Cross_Refresh.refreshCrossDesigns()`
   한 창구로 모으고 세션 편집값은 저장소에서 직접 읽어 패널 없는 B05도 같은 값을 보낸다.

③ 낡음 판정 — 「옛 암 2단계 필드 누락」 조건이 B06 페이지에만 있어 B05는 재계산을
   건너뛰었다. 공용 `staleDesignChainages()` 안으로 옮겨 두 화면이 같은 시점에 같은
   조치를 한다.

검증 — 공용 브라우저 실측: 두 화면 모두 route 126, 요약줄 문자열 완전 일치
(`절토(자연) 4,526.8㎥ · 성토 14,881.8㎥ · 토취 10,213.3㎥ · 최종 누가토량 −10,213.3㎥`).
pytest 370 passed·17 skipped(일원화 검사 4건 신설), typecheck·prettier·ruff 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 09:50:14 +09:00

254 lines
10 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Page_Persist.ts
* B06 페이지의 **저장 흐름** — 암 경계선 세션 저장소, 편집분 수집, [저장]·[확정].
*
* `B06_Section_UI_Page` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 화면 조립·조정창
* 배선은 페이지에 남고, 여기에는 "세션에 쌓인 조작을 정본으로 내보내는" 경로만 둔다
* (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만).
* ========================================================================== */
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import {
confirmSections,
saveSections,
type CrossSectionPatch,
type SectionContextResponse,
type SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { L } from "./B06_Section_UI_Page_Common";
/**
* 암 경계선 오프셋 세션 키 — 저장소와 재계산 창구가 **같은 자리**를 보게 정의처를 하나로 둔다.
* (`B06_Section_Cross_Refresh` 가 패널 없는 B05에서도 같은 값을 읽어 서버로 보낸다.)
*/
export function rockBoundarySessionKey(projectId: string, routeId: number): string {
return `b06:rockb:${projectId}:${routeId}`;
}
/** 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. */
export function readRockBoundarySession(
projectId: string,
routeId: number,
): Record<string, number> {
try {
const raw = window.sessionStorage.getItem(rockBoundarySessionKey(projectId, routeId));
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
return parsed && typeof parsed === "object" ? (parsed as Record<string, number>) : {};
} catch {
return {};
}
}
/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */
export interface RockBoundaryStore {
/** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */
offsets: Map<string, number>;
control: RockBoundaryControl;
/** 세션값 읽기 — 프로젝트·노선이 정해진 뒤에 부른다. */
load: () => void;
/** context(config) 기본값·스텝 반영. */
setDefaults: (defaultOffsetM: number, stepM: number) => void;
}
/**
* 암 경계선 오프셋(측점별) 세션 저장소.
*
* 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시
* cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다.
*/
export function createRockBoundaryStore(options: {
sessionKey: () => string | null;
detail: () => SectionDetailResponse | null;
refreshCard: (chainageM: number) => void;
/** 경계 이동 → 2단계 무릎·단면적 재계산. */
recompute: (chainageM: number) => void;
}): RockBoundaryStore {
const { sessionKey, detail, refreshCard, recompute } = options;
let rockBoundaryDefault = -0.5;
let rockBoundaryStep = 0.1;
/**
* 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서
* 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다.
* DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다.
*/
const clamp = (value: number): number => Math.min(value, 0);
const offsets = new Map<string, number>();
const key = (chainageM: number): string => chainageM.toFixed(2);
function persist(): void {
const storageKey = sessionKey();
if (!storageKey) return;
try {
window.sessionStorage.setItem(storageKey, JSON.stringify(Object.fromEntries(offsets)));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
}
return {
offsets,
load(): void {
offsets.clear();
const storageKey = sessionKey();
if (!storageKey) return;
try {
const raw = window.sessionStorage.getItem(storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([chainage, offset]) => {
if (Number.isFinite(offset)) offsets.set(chainage, offset);
});
} catch {
/* 손상된 세션 값은 무시 — 기본값으로 재시작. */
}
},
setDefaults(defaultOffsetM: number, stepM: number): void {
rockBoundaryDefault = defaultOffsetM;
rockBoundaryStep = stepM;
},
control: {
get stepM() {
return rockBoundaryStep;
},
get defaultOffsetM() {
return rockBoundaryDefault;
},
offsetFor: (section) =>
clamp(
offsets.get(key(section.chainage_m)) ??
section.design?.rock_boundary_offset_m ??
rockBoundaryDefault,
),
adjust: (chainageM, deltaM) => {
const stored = detail()?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
)?.design?.rock_boundary_offset_m;
const current = clamp(offsets.get(key(chainageM)) ?? stored ?? rockBoundaryDefault);
offsets.set(key(chainageM), clamp(Math.round((current + deltaM) * 100) / 100));
persist();
refreshCard(chainageM);
recompute(chainageM);
},
reset: (chainageM) => {
offsets.set(key(chainageM), rockBoundaryDefault);
persist();
refreshCard(chainageM);
recompute(chainageM);
},
},
};
}
/** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */
export interface SectionPersistContext {
projectId: string | null;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
context: () => SectionContextResponse | null;
standardValues: () => StandardCrossSection | undefined;
/** 조정창 구간값을 정본으로 내보낸다(세션 → 서버). */
flushCulvertOptions: () => Promise<void>;
/** 측점별 편집분의 출처 묶음 — 세션·제어기에 흩어진 값을 페이지가 모아 준다. */
patchSources: () => CrossPatchSources;
}
/** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */
export function collectSectionEdits(ctx: SectionPersistContext): {
crossPatches: CrossSectionPatch[];
massHaul: Record<string, unknown> | undefined;
} {
const crossPatches = buildCrossPatches(ctx.patchSources());
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
const detail = ctx.detail();
const context = ctx.context();
const result =
detail && context?.earthwork_conversion
? computeMassHaul(
detail.cross_sections,
context.earthwork_conversion,
context.natural_spoil_min_ground_slope ?? undefined,
)
: null;
return {
crossPatches,
massHaul: result
? massHaulPayload(
result,
computeHaulPlan(result, context?.haul_equipment_limits),
balloonOffsetsPayload(),
)
: undefined,
};
}
/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */
async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise<void> {
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
await ctx.flushCulvertOptions();
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
await flushPendingStructures(projectId).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`구조물 저장에 실패했습니다.${detail}`, "error");
});
}
/** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */
export async function saveCurrentSections(ctx: SectionPersistContext): Promise<void> {
const routeId = ctx.routeId();
if (!ctx.projectId || routeId === null) return;
showLoadingOverlay();
try {
await flushPendingEdits(ctx, ctx.projectId);
const edits = collectSectionEdits(ctx);
await saveSections(
ctx.projectId,
routeId,
ctx.standardValues(),
edits.crossPatches.length ? edits.crossPatches : undefined,
edits.massHaul,
);
showToast(L("B06_Profile_Save_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
/** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */
export async function confirmCurrentSections(ctx: SectionPersistContext): Promise<void> {
const routeId = ctx.routeId();
if (!ctx.projectId || routeId === null) return;
showLoadingOverlay();
try {
await flushPendingEdits(ctx, ctx.projectId);
const edits = collectSectionEdits(ctx);
await confirmSections(
ctx.projectId,
routeId,
ctx.standardValues(),
edits.crossPatches.length ? edits.crossPatches : undefined,
edits.massHaul,
);
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(ctx.projectId, WORKFLOW_STEP_ROUTES[4]);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}