/* ============================================================================= * A00_Common/design/design_save.ts * B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례. * * 노선 → 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 * * 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan` * (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고 * 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 → * 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움. * 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄). * * 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일** * (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라, * 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다. * * ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은 * 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12). * 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음). * ========================================================================== */ import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { flushPendingPipes, readPendingPipes, writePendingPipes, } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { rebasePipeDraft } from "../../common_util/common_util_route_follow"; import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides, saveProfileAlignment, } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { markAlignmentSaved, pendingAlignment, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; import { clearState, stateKey } from "../b_page_state"; import { carryAfterRouteSave, pendingRouteSave, rebuildAfterRouteSave, routeSaveSummary, saveRouteDraft, } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply"; /** 저장 차례 — 이 순서 그대로 돈다(`route` 는 노선 초안이 있을 때만). */ export const DESIGN_SAVE_ORDER = [ "route", "pipes", "structures", "alignment", "culvertOptions", "uphill", "cross", ] as const; export type DesignSaveStep = (typeof DESIGN_SAVE_ORDER)[number]; export interface DesignSaveContext { projectId: string; routeId: () => number | null; /** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */ prepare?: () => Promise; /** * 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다. * `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤). */ steps?: Partial, () => Promise>>; /** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */ cross: (confirm: boolean) => Promise; /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */ done?: (confirm: boolean) => Promise | void; /** 멈춘 저장을 알린다. */ failed: (error: unknown, confirm: boolean) => void; } const saving = new Set(); /** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */ export function savingDesign(projectId: string | null): boolean { return projectId !== null && saving.has(projectId); } /** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */ function reportOnly(label: string): (error: unknown) => void { return (error) => { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${label} 저장에 실패했습니다.${detail}`, "error"); }; } /** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */ export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void { const dropped = saved.dropped_edit_chainages ?? []; if (!dropped.length) return; const where = dropped .slice(0, 5) .map((value) => `${value.toFixed(2)}m`) .join(" · "); showToast( `계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` + (dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""), "info", ); } /** 노선 초안 동안의 계획고 초안 칸 — `design_pipeline_page` 의 `DRAFT_ROUTE_ID` 와 같은 값(sub7). */ const DRAFT_ROUTE_ID = 0; /** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. 노선이 바뀌었으면 * 노선 초안 칸(`DRAFT_ROUTE_ID`) · 없으면 옛 노선 칸(`from`)의 초안을 새 노선(`routeId`)에 · 두 칸 다 * 비움 — 측점이 없는 자리는 서버가 버리고 알림(213-2). */ async function flushAlignmentDraft( projectId: string, routeId: number, from: number = routeId, ): Promise { const moved = from !== routeId; const draft = (moved ? pendingAlignment(DRAFT_ROUTE_ID) : null) ?? pendingAlignment(from); if (!draft) return; noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft)); markAlignmentSaved(from); if (moved) markAlignmentSaved(DRAFT_ROUTE_ID); } /** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */ const TOLERANT: Partial> = { pipes: "배수관", structures: "구조물", alignment: "계획선", }; /** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */ function defaultStep( step: Exclude, projectId: string, routeId: number, fromRouteId: number = routeId, ): () => Promise { switch (step) { case "pipes": return () => flushPendingPipes(projectId); case "structures": return () => flushPendingStructures(projectId); case "alignment": return () => flushAlignmentDraft(projectId, routeId, fromRouteId); case "culvertOptions": return () => flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)).catch( () => undefined, ); case "uphill": return () => flushUphillOverrides(projectId).catch(() => undefined); } } type SavedPipes = Awaited>["pipe_points"]; /** 세션 관 초안이 있으면 노선 저장 전 정본 관(id · 자리) — 없으면 · 못 받으면 null(얹기 안 함). */ const savedPipesIfDraft = (projectId: string): Promise => readPendingPipes(projectId) ? fetchDetailPipePoints(projectId) .then((detail) => detail.pipe_points) .catch(() => null) : Promise.resolve(null); /** 노선을 저장함 — 서버가 관을 새 노선으로 따라 옮김 · 새 교차 관을 놓음. 세션 관 초안(옛 노선 누가거리일 수 * 있음)을 그 위에 얹어 둠 — 관 차례가 서버 따라감을 덮지 않게(349-0u). 못 받으면 초안 그대로. */ async function rebasePendingPipes(projectId: string, before: SavedPipes): Promise { const draft = readPendingPipes(projectId); const after = await fetchDetailPipePoints(projectId).catch(() => null); if (!draft || !after) return; const moved = after.pipe_points.map(({ lonlat: _lonlat, ...pipe }) => pipe); writePendingPipes(projectId, rebasePipeDraft(draft, before, moved)); } /** * [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다. * 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true. */ export async function saveDesign( ctx: DesignSaveContext, options: { confirm?: boolean } = {}, ): Promise { const confirm = options.confirm ?? false; const fromRouteId = ctx.routeId(); if (fromRouteId === null) return false; let routeId = fromRouteId; /** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */ let routeSaved: Awaited> | null = null; showLoadingOverlay(); saving.add(ctx.projectId); try { await ctx.prepare?.(); for (const step of DESIGN_SAVE_ORDER) { if (step === "route") { const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄 if (!body) continue; const before = await savedPipesIfDraft(ctx.projectId); routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림) if (routeSaved.route_id != null) routeId = routeSaved.route_id; if (before) await rebasePendingPipes(ctx.projectId, before); continue; } if (step === "cross") { if (!routeSaved) await ctx.cross(confirm); else { const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); showToast(routeSaveSummary(routeSaved, outside), "info"); } continue; } // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. const own = routeSaved ? undefined : ctx.steps?.[step]; const base = defaultStep(step, ctx.projectId, routeId, fromRouteId); const run = step === "pipes" ? async () => { await base(); await own?.(); } : (own ?? base); const label = TOLERANT[step]; await (label ? run().catch(reportOnly(label)) : run()); } clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림 if (routeSaved) { // 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움. if (confirm) showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info"); else await ctx.done?.(false); await rebuildAfterRouteSave(ctx.projectId); return true; } await ctx.done?.(confirm); return true; } catch (error) { ctx.failed(error, confirm); return false; } finally { saving.delete(ctx.projectId); hideLoadingOverlay(); } }