/* ============================================================================= * A00_Common/design/design_save.ts * B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례. * * 노선 → 페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 * * 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan` * (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고 * 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 → * 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움. * 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄). * 노선 저장 뒤 다른 차례가 실패하면 초안을 새 노선 칸으로 옮기고 페이지를 새 노선으로(433-12). * 어느 차례든 실패하면 그 차례에서 멈추고 차례 이름째 알림(429 ⑨ · 뒤 차례는 안 보냄). * 노선을 저장했으면 횡단은 고침이 없어도 한 번 나감 — 뒤에 나간 계획고 초안을 서버가 횡단에 다시 셈(429 ④). * * 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일** * (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라, * 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다. * * ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은 * 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12). * 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음). * ========================================================================== */ import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { flushPendingPipes, pipesPart, readPendingPipes, writePendingPipes, } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { rebasePipeDraft, rebaseStructureDraft } from "../../common_util/common_util_route_follow"; import { fetchStructures, flushPendingStructures, readPendingStructures, structuresPart, writePendingStructures, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { flushPlusStations, outsideNotice, plusStationsPart, } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { flushRouteParts, routePartsBody, saveProfileAlignment, } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { markAlignmentSaved, pendingAlignment, writeAlignmentDraft, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { culvertOptionsPart, flushCulvertOptions, } from "../../B06_Section/B06_Section_Api_Culvert_Options"; import { flushPageSettings, pageSettingsPart, } from "../../B05_Profile/B05_Profile_Api_PageSettings"; import { DesignSaveError, postDesignSave, SAVE_PART_KEYS, type SavePart, type SavePartKey, } from "./design_save_api"; 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", "settings", "pipes", "structures", "plusStations", "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; /** * 한 본문 차례 조각을 보기가 고침(472-6) — 받는 값 = 기본 조각(세션 초안 → 본문 · 없으면 null) · * 돌려주는 조각이 그 차례 본문 · 저장 뒤 정리(보기 화면 맞춤을 덧붙이거나 보기 목록으로 갈음). */ parts?: Partial>; /** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */ crossPart: (confirm: boolean) => Promise; /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */ done?: (confirm: boolean) => Promise | void; /** 멈춘 저장을 알린다. */ failed: (error: unknown, confirm: boolean) => void; } export type PartOverride = ( base: SavePart | null, confirm: boolean, ) => Promise | SavePart | null; const saving = new Set(); /** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */ export function savingDesign(projectId: string | null): boolean { return projectId !== null && saving.has(projectId); } /** 차례 이름 — 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게 · * 초안은 세션에 남아 다음 [저장]에서 다시 나감). */ const STEP_LABEL: Record = { route: "노선", settings: "페이지 설정", pipes: "배수관", structures: "구조물", plusStations: "추가 측점", alignment: "계획선", culvertOptions: "암거 옵션", uphill: "상단측", cross: "횡단", }; function stepFailed(step: DesignSaveStep, error: unknown): Error { const detail = error instanceof Error ? ` ${error.message}` : ""; return new Error(`${STEP_LABEL[step]} 차례에서 멈췄습니다(뒤 차례 안 보냄).${detail}`); } /** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 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); } /** 노선은 저장했는데 뒤 차례가 실패 — 계획고 초안(노선 초안 칸 · 옛 노선 칸)을 새 노선 칸에 · 횡단 초안을 새 * 측점 열쇠로(저장 없이 · 이미 넘겼으면 안 함) · 페이지를 새 노선으로 다시 세움(433-12). 다음 [저장]이 그대로 보냄. */ async function keepDraftsOnNewRoute( projectId: string, fromRouteId: number, routeId: number, carried: boolean, ): Promise { const draft = pendingAlignment(DRAFT_ROUTE_ID) ?? pendingAlignment(fromRouteId); if (draft) { writeAlignmentDraft(routeId, draft); markAlignmentSaved(DRAFT_ROUTE_ID); markAlignmentSaved(fromRouteId); } if (!carried) await carryAfterRouteSave(projectId, fromRouteId, routeId, { save: false }); await rebuildAfterRouteSave(projectId); } /** 차례마다 기본 창구. */ function defaultStep( step: Exclude, projectId: string, routeId: number, fromRouteId: number = routeId, confirm = false, ): () => Promise { switch (step) { case "settings": // 노선 뒤 — 노선 저장이 새로 만든 종단에도 경고 기준이 얹힘(448) return () => flushPageSettings(projectId); case "pipes": return () => flushPendingPipes(projectId, confirm); case "structures": return () => flushPendingStructures(projectId); case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2) return () => flushPlusStations(projectId); case "alignment": return () => flushAlignmentDraft(projectId, routeId, fromRouteId); case "culvertOptions": return () => flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)); case "uphill": return () => flushRouteParts(projectId); // 종단 보기 몫과 같은 본문(463) } } 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)); } type SavedStructures = Awaited>["structures"]; /** 세션 구조물 초안이 있으면 노선 저장 전 정본 — 없으면 · 못 받으면 null(얹기 안 함 · 459). */ const savedStructuresIfDraft = (projectId: string): Promise => readPendingStructures(projectId) ? fetchStructures(projectId) .then((list) => list.structures) .catch(() => null) : Promise.resolve(null); /** 노선을 저장함 — 서버가 구조물을 좌표로 따라 옮김. 세션 구조물 초안을 그 위에 얹음(459 · 관 짝) — 구조물 * 차례가 서버 따라감을 브라우저 누가거리로 덮지 않게. 못 받으면 초안 그대로. */ async function rebasePendingStructures(projectId: string, before: SavedStructures): Promise { const draft = readPendingStructures(projectId); const after = await fetchStructures(projectId).catch(() => null); if (!draft || !after) return; writePendingStructures(projectId, rebaseStructureDraft(draft, before, after.structures)); } /** * [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다. * 노선이 없으면 아무 일도 하지 않고 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; // 노선 초안이 없으면 한 본문 한 요청(472-6 6-A · 서버가 전부 아니면 없음) · 있으면 옛 차례(6-B 전) const routeBody = await pendingRouteSave(ctx.projectId); if (!routeBody) return saveOneBody(ctx, confirm, fromRouteId); let routeId = fromRouteId; /** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */ let routeSaved: Awaited> | null = null; /** 횡단 초안을 새 노선으로 넘기기 시작함 — 실패 길에서 두 번 넘기지 않게(누가거리 칸은 제자리에서 옮김). */ let carried = false; showLoadingOverlay(); saving.add(ctx.projectId); try { await ctx.prepare?.(); for (const step of DESIGN_SAVE_ORDER) { try { if (step === "route") { const body = routeBody; const [before, beforeStructures] = await Promise.all([ savedPipesIfDraft(ctx.projectId), savedStructuresIfDraft(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); if (beforeStructures) await rebasePendingStructures(ctx.projectId, beforeStructures); const outside = (routeSaved.pipes?.outside ?? 0) + (routeSaved.structures?.outside ?? 0); if (outside) showToast(outsideNotice(outside), "warning"); if (routeSaved.follow_failed?.length) showToast( `${routeSaved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`, "error", ); continue; } if (step === "cross") { carried = true; const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); if (routeSaved) showToast(routeSaveSummary(routeSaved, outside), "info"); continue; } // 상단측은 옛 누가거리 열쇠 — 노선이 바뀌면 횡단 차례(`carryAfterRouteSave`)가 새 측점으로 옮긴 뒤 보냄(443-2) if (step === "uphill" && routeSaved) continue; // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. await defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm)(); } catch (error) { throw stepFailed(step, error); // 그 차례에서 멈춤 · 이름째 알림(429 ⑨) } } 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) { if (routeSaved) await keepDraftsOnNewRoute(ctx.projectId, fromRouteId, routeId, carried).catch( () => undefined, // 못 옮김 — 초안은 옛 칸에 남음(알림은 아래 한 줄) ); ctx.failed(error, confirm); return false; } finally { saving.delete(ctx.projectId); hideLoadingOverlay(); } } /** 한 본문 기본 조각 — 세션 초안 그대로(옛 기본 창구와 같은 본문). */ async function defaultPart( key: Exclude, projectId: string, routeId: number, confirm: boolean, ): Promise { switch (key) { case "settings": return pageSettingsPart(projectId); case "pipes": return pipesPart(projectId, confirm); case "structures": return structuresPart(projectId); case "plusStations": return plusStationsPart(projectId); case "alignment": { const draft = pendingAlignment(routeId); if (!draft) return null; return { body: { route_id: routeId, ...draft }, after: (response) => { if (response) noticeDroppedEdits(response); markAlignmentSaved(routeId); }, }; } case "culvertOptions": return culvertOptionsPart(stateKey("culvertopt", projectId, routeId)); case "uphill": return { body: await routePartsBody(projectId), after: () => clearState("uphill", projectId), }; } } /** 서버 실패 → 화면 글 — 차례 이름째 · 모두 그대로 · 되돌림마저 실패면 「복구 확인 필요」. */ function oneBodyError(error: unknown): Error { if (!(error instanceof DesignSaveError)) return error instanceof Error ? error : new Error(String(error)); if (!error.restored) return new Error(`저장 실패 · 복구 확인 필요 — ${error.message}`); const where = error.step ? `${STEP_LABEL[error.step]} 차례에서 ` : ""; return new Error(`${where}실패 — 아무것도 저장하지 않았습니다(모두 그대로). ${error.message}`); } /** 노선 안 바뀐 [저장] · [확정] — 조각을 모아 서버 한 창구에 한 번(472-6 6-A). 서버가 다 저장한 뒤에만 조각 * 정리(초안 비움 · 화면 맞춤) · 실패면 초안 그대로(다음 [저장]에서 다시). */ async function saveOneBody( ctx: DesignSaveContext, confirm: boolean, routeId: number, ): Promise { showLoadingOverlay(); saving.add(ctx.projectId); try { await ctx.prepare?.(); const parts: Partial> = {}; for (const key of SAVE_PART_KEYS) { if (key === "cross") { parts.cross = await ctx.crossPart(confirm); continue; } const base = await defaultPart(key, ctx.projectId, routeId, confirm); const own = ctx.parts?.[key]; parts[key] = own ? await own(base, confirm) : base; } const result = await postDesignSave(ctx.projectId, routeId, confirm, parts); if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info"); for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(result.steps[key]); clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림 await ctx.done?.(confirm); return true; } catch (error) { ctx.failed(oneBodyError(error), confirm); return false; } finally { saving.delete(ctx.projectId); hideLoadingOverlay(); } }