feat(472-6 6-A 브라우저 한 본문): 노선 안 바뀐 [저장] · [확정] = 차례 조각을 모아 /design/save 한 요청

- design_save: 노선 초안이 없으면 saveOneBody — 차례마다 조각(본문 · 저장 뒤 정리) · 서버가 다 저장한 뒤에만 정리(초안 비움 · 화면 맞춤) · 실패 = 차례 이름째 「아무것도 저장 안 함」 · 되돌림 실패 = 「복구 확인 필요」 · 기다림 알림
- 조각 함수: 페이지 설정 · 관 · 구조물(보기 목록) · 추가 측점 · 계획고(한 벌 종단도) · 관 옵션 · 상단측 · 횡단(횡단 보기) — 옛 flush 함수는 노선 바뀐 저장 길에 그대로(조각을 써서 같은 본문)
- 보기 몫 steps · cross → parts · crossPart · 노선 바뀐 저장 길은 옛 차례 그대로(6-B)
- 옛 9 차례를 단언하던 시험 10 개를 한 본문 꼴로(옛 PUT 줄 → SAVE 줄 · 조각 정리 차례) · 위키 [저장] 표

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 10:41:58 +09:00
co-authored by Claude Opus 5.5
parent 12c3282abf
commit 63f994972c
30 changed files with 668 additions and 165 deletions
+37 -13
View File
@@ -23,7 +23,11 @@ import {
toDesignProfile,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
import {
saveProfileAlignment,
type ProfileAlignmentSaveResponse,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import type { SavePart } from "./design_save_api";
import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common";
import {
blocksMinCover,
@@ -297,6 +301,28 @@ export function createDesignProfile(ctx: DesignProfileContext) {
listedBase(storedBase, detail, planted ? irregularStations : null);
/** 편집 후 다시 그린다 — 길게 누르기(초당 10회)도 한 프레임에 한 번만 그림. */
/** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */
function alignmentSavePart(): SavePart | null {
if (!routeId || !store.dirty() || store === preview) return null; // 노선 초안 미리보기 편집은 안 보냄
return {
body: { route_id: routeId, ...store.edits() },
after: (response) => adoptSaved(response as unknown as ProfileAlignmentSaveResponse),
};
}
function adoptSaved(saved: ProfileAlignmentSaveResponse): void {
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
draw();
}
function rebuild(): void {
if (base) alignment = buildAlignment(base, store.edits());
pagePipeline.pipelineGrade(projectId, store.edits());
@@ -597,19 +623,17 @@ export function createDesignProfile(ctx: DesignProfileContext) {
},
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> {
if (!routeId || !store.dirty() || store === preview) return; // 노선 초안 미리보기 편집은 안 보냄
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
draw();
const part = alignmentSavePart();
if (!part) return;
await part.after?.(
(await saveProfileAlignment(projectId, routeId!, store.edits())) as unknown as Record<
string,
unknown
>,
);
},
/** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */
savePart: () => alignmentSavePart(),
clear() {
detail = null;
base = null;
+128 -27
View File
@@ -25,6 +25,7 @@
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import {
flushPendingPipes,
pipesPart,
readPendingPipes,
writePendingPipes,
} from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
@@ -34,17 +35,39 @@ import {
fetchStructures,
flushPendingStructures,
readPendingStructures,
structuresPart,
writePendingStructures,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { flushPlusStations, outsideNotice } from "../../B05_Profile/B05_Profile_Api_PlusStations";
import { flushRouteParts, saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
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 { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import { flushPageSettings } from "../../B05_Profile/B05_Profile_Api_PageSettings";
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,
@@ -75,18 +98,23 @@ export interface DesignSaveContext {
/** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */
prepare?: () => Promise<void>;
/**
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(저장분으로 화면만 맞춤 · PUT 없음 · 433-7).
* 한 본문 차례 조각을 보기가 고침(472-6) — 받는 값 = 기본 조각(세션 초안 → 본문 · 없으면 null) ·
* 돌려주는 조각이 그 차례 본문 · 저장 뒤 정리(보기 화면 맞춤을 덧붙이거나 보기 목록으로 갈음).
*/
steps?: Partial<Record<Exclude<DesignSaveStep, "cross" | "route">, () => Promise<void>>>;
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
cross: (confirm: boolean) => Promise<void>;
parts?: Partial<Record<SavePartKey, PartOverride>>;
/** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */
crossPart: (confirm: boolean) => Promise<SavePart | null>;
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
done?: (confirm: boolean) => Promise<void> | void;
/** 멈춘 저장을 알린다. */
failed: (error: unknown, confirm: boolean) => void;
}
export type PartOverride = (
base: SavePart | null,
confirm: boolean,
) => Promise<SavePart | null> | SavePart | null;
const saving = new Set<string>();
/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */
@@ -241,6 +269,9 @@ export async function saveDesign(
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<ReturnType<typeof saveRouteDraft>> | null = null;
@@ -253,8 +284,7 @@ export async function saveDesign(
for (const step of DESIGN_SAVE_ORDER) {
try {
if (step === "route") {
const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄
if (!body) continue;
const body = routeBody;
const [before, beforeStructures] = await Promise.all([
savedPipesIfDraft(ctx.projectId),
savedStructuresIfDraft(ctx.projectId),
@@ -273,27 +303,15 @@ export async function saveDesign(
continue;
}
if (step === "cross") {
if (!routeSaved) await ctx.cross(confirm);
else {
carried = true;
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
showToast(routeSaveSummary(routeSaved, outside), "info");
}
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;
// 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로.
const own = routeSaved ? undefined : ctx.steps?.[step];
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm);
const run =
step === "pipes"
? async () => {
await base();
await own?.();
}
: (own ?? base);
await run();
await defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm)();
} catch (error) {
throw stepFailed(step, error); // 그 차례에서 멈춤 · 이름째 알림(429 ⑨)
}
@@ -322,3 +340,86 @@ export async function saveDesign(
hideLoadingOverlay();
}
}
/** 한 본문 기본 조각 — 세션 초안 그대로(옛 기본 창구와 같은 본문). */
async function defaultPart(
key: Exclude<SavePartKey, "cross">,
projectId: string,
routeId: number,
confirm: boolean,
): Promise<SavePart | null> {
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<boolean> {
showLoadingOverlay();
saving.add(ctx.projectId);
try {
await ctx.prepare?.();
const parts: Partial<Record<SavePartKey, SavePart | null>> = {};
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();
}
}
+100
View File
@@ -0,0 +1,100 @@
/* =============================================================================
* A00_Common/design/design_save_api.ts
* [저장] · [확정] 한 본문(PLAN 472-6 6-A) — 차례 조각 꼴 · 서버 한 창구 `POST /design/save`.
*
* 차례마다 조각 하나 = 본문(없으면 null · 서버가 그 차례를 건너뜀) + 저장 뒤 정리(초안 비움 · 화면 맞춤).
* 서버는 전부 저장하거나 아무것도 안 바꿈 — 실패면 차례 이름 · 까닭(`restored` 거짓 = 복구 확인 필요).
* ========================================================================== */
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL } from "@config/config_frontend";
/** 서버 본문 칸 차례 그대로(`B06_Section_Router_DesignSave`). */
export const SAVE_PART_KEYS = [
"settings",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
"cross",
] as const;
export type SavePartKey = (typeof SAVE_PART_KEYS)[number];
/** 차례 조각 — `after` 는 서버가 다 저장한 뒤에만(그 차례 응답을 받음). */
export interface SavePart {
body: unknown | null;
after?: (response: Record<string, unknown> | undefined) => Promise<void> | void;
}
const BODY_KEY: Record<SavePartKey, string> = {
settings: "settings",
pipes: "pipes",
structures: "structures",
plusStations: "plus_stations",
alignment: "alignment",
culvertOptions: "culvert_options",
uphill: "uphill",
cross: "cross",
};
export interface DesignSaveResult {
waited: boolean;
steps: Partial<Record<SavePartKey, Record<string, unknown>>>;
}
/** 실패 — 차례 이름(`step`) · 되돌림 됨(`restored`). */
export class DesignSaveError extends Error {
constructor(
message: string,
readonly step: SavePartKey | null,
readonly restored: boolean,
) {
super(message);
}
}
/** 조각 본문을 모아 한 번에 보냄. */
export async function postDesignSave(
projectId: string,
routeId: number,
confirm: boolean,
parts: Partial<Record<SavePartKey, SavePart | null>>,
): Promise<DesignSaveResult> {
const body: Record<string, unknown> = { route_id: routeId, confirm };
for (const key of SAVE_PART_KEYS) {
const part = parts[key];
if (part && part.body !== null && part.body !== undefined) body[BODY_KEY[key]] = part.body;
}
const controller = new AbortController();
// [확정]은 낡은 배수유역을 다시 셈 — 옛 차례들의 시간 한도 합 만큼
const timer = window.setTimeout(() => controller.abort(), API_ANALYSIS_TIMEOUT_MS * 3);
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/design/save`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok)
throw new DesignSaveError(
payload?.message ?? `HTTP ${response.status}`,
(payload?.step as SavePartKey) ?? null,
payload?.restored !== false,
);
return { waited: payload?.waited === true, steps: payload?.steps ?? {} };
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError")
throw new DesignSaveError(
"저장 응답을 시간 안에 못 받았습니다 — 새로고침해 저장됐는지 확인해 주세요.",
null,
false,
);
throw error;
} finally {
window.clearTimeout(timer);
}
}
+26 -15
View File
@@ -27,7 +27,8 @@ import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"
import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
import { saveDesign, type DesignSaveStep } from "./design_save";
import { saveDesign, type PartOverride } from "./design_save";
import type { SavePart, SavePartKey } from "./design_save_api";
import { markDesignClean } from "./design_leave";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
@@ -71,10 +72,10 @@ export interface DesignProfileBinding {
export interface DesignViewSave {
routeId: () => number | null;
prepare?: () => Promise<void>;
steps?: Partial<
Record<Exclude<DesignSaveStep, "cross" | "pipes" | "alignment">, () => Promise<void>>
>;
cross?: (confirm: boolean) => Promise<void>;
/** 한 본문 차례 조각 고침(472-6) — 구조물 · 상단측(종단 보기) 등. */
parts?: Partial<Record<SavePartKey, PartOverride>>;
/** 횡단 조각 — 횡단 보기 몫(표준 횡단 · 카드 고침 · 유토). */
crossPart?: (confirm: boolean) => Promise<SavePart | null>;
/** 저장 뒤 이 보기만의 캐시 비우기 · 다시 읽기(알림 · 다음 단계는 여기서 한다). */
done?: (confirm: boolean) => Promise<void> | void;
}
@@ -399,8 +400,8 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
await shared.ensureView("cross");
const profileSave = shared.bindings.profile?.save;
const crossSave = shared.bindings.cross?.save;
const crossBody = crossSave?.cross;
if (!crossBody) {
const crossPart = crossSave?.crossPart;
if (!crossPart) {
showToast(L(confirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed"), "error");
return;
}
@@ -411,16 +412,26 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
projectId: shared.projectId,
routeId: () => routeIdOf(shared),
prepare: crossSave?.prepare,
steps: {
// 관 정본 PUT 은 세션 초안 한 번(기본 창구) — 배수유역도는 저장분을 다시 받아 화면만 맞춤(433-7 · 판
parts: {
// 관 본문은 세션 초안(기본 조각) — 배수유역도는 저장 뒤 저장분을 다시 받아 화면만 맞춤(433-7 · 판
// 메모리를 다시 보내면 B06 고침 직후 · 판이 안 선 [저장]에서 관을 잃음).
...(side ? { pipes: async () => side.reload(confirm) } : {}),
// 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다.
...(profile ? { alignment: () => profile.save() } : {}),
...profileSave?.steps,
...crossSave?.steps,
...(side
? {
pipes: (base: SavePart | null) => ({
body: base?.body ?? null,
after: async (response: Record<string, unknown> | undefined) => {
await base?.after?.(response);
await side.reload(confirm);
},
}),
}
: {}),
// 계획고 초안 — 한 벌 종단도가 본문을 내고 저장 뒤 서버 선형으로 다시 그린다.
...(profile ? { alignment: () => profile.savePart() } : {}),
...profileSave?.parts,
...crossSave?.parts,
},
cross: crossBody,
crossPart,
done: async (isConfirm) => {
markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
if (isConfirm) {
+7 -2
View File
@@ -255,17 +255,22 @@ function uphillOverridesOf(
* 횡단을 맞춤) · 비정규 목록 = 빈 목록(서버가 정본 관 · 구조물에서 셈) · 상단측 = 세션. 종단 보기를 열었든
* 안 열었든 같은 요청(기본 창구 · 종단 보기 몫 둘 다 이것). */
export async function flushRouteParts(projectId: string): Promise<void> {
await saveRouteParts(projectId, await routePartsBody(projectId));
}
/** 상단측 차례 본문 — 노선 캐시 지표 조건 + 확정 지표면 id + 세션 상단측. */
export async function routePartsBody(projectId: string): Promise<RouteConfirmRequest> {
const latest =
readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId).catch(() => null));
const surface = await fetchConfirmedSurface(projectId).catch(() => null);
await saveRouteParts(projectId, {
return {
filter_key: latest?.surface_params?.source_filter,
method: latest?.surface_params?.method,
smooth: latest?.surface_params?.smooth,
surface_model_id: surface?.model_id ?? undefined,
irregular_stations: [],
uphill_overrides: uphillOverridesOf(projectId),
});
};
}
/** [저장] 차례의 확정(단계 전이 없음) — 하위 차례가 실패하면 던짐(443 · 저장 한 줄기가 그 차례에서 멈춤 ·
+14 -2
View File
@@ -19,6 +19,7 @@ import {
type PageSettingsBody,
} from "./B05_Profile_Api_Fetch";
import type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields";
import type { SavePart } from "../A00_Common/design/design_save_api";
export type PageSettingsDraft = PageSettingsBody;
@@ -68,9 +69,20 @@ onPageHistory({
/** [저장] `settings` 차례 — 초안이 없으면 안 보냄 · 성공하면 최신 응답 캐시에 얹고 초안을 비움
* (실패 = 던짐 · 초안 그대로 다음 [저장]에서 다시). */
export async function flushPageSettings(projectId: string): Promise<void> {
const part = pageSettingsPart(projectId);
if (!part) return;
await savePageSettings(projectId, part.body as PageSettingsDraft);
part.after?.(undefined);
}
/** 한 본문 [저장] 조각(472-6) — 초안이 없으면 null · 저장 뒤 최신 응답 캐시에 얹고 초안 비움. */
export function pageSettingsPart(projectId: string): SavePart | null {
const draft = readPageSettings(projectId);
if (!draft) return;
await savePageSettings(projectId, draft);
if (!draft) return null;
return { body: draft, after: () => adoptPageSettings(projectId, draft) };
}
function adoptPageSettings(projectId: string, draft: PageSettingsDraft): void {
const latest = readRouteLatestCache(projectId);
if (latest?.route_params) {
const { design_speed_kph, terrain_type, ...top } = draft;
@@ -15,6 +15,7 @@
import { useDesignStore } from "../A00_Common/design/design_store";
import { saveDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { SavePart } from "../A00_Common/design/design_save_api";
/** 세션에 담는 관 한 벌 — 정본 PUT 이 그대로 받는 꼴이다. */
export type PendingPipes = DetailPipeInput[];
@@ -35,3 +36,14 @@ export async function flushPendingPipes(projectId: string, confirm = false): Pro
await saveDetailPipePoints(projectId, pending, confirm);
writePendingPipes(projectId, null);
}
/** 한 본문 [저장] 조각(472-6) — 초안 = `{points}` · 초안 없는 [확정] = 빈 본문(서버 저장분으로 다시 셈) ·
* 초안 없는 [저장] = null. 저장 뒤 초안 비움. */
export function pipesPart(projectId: string, confirm: boolean): SavePart | null {
const pending = readPendingPipes(projectId);
if (!pending && !confirm) return null;
return {
body: pending ? { points: pending } : {},
after: () => writePendingPipes(projectId, null),
};
}
+22 -7
View File
@@ -10,6 +10,7 @@
import { API_BASE_URL, API_TIMEOUT_MS } from "../config/config_frontend";
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
import { onPageHistory } from "./B05_Profile_UI_Profile_History";
import type { SavePart } from "../A00_Common/design/design_save_api";
const saved = new Map<string, number[]>();
const loaded = new Map<string, Promise<void>>();
@@ -126,6 +127,16 @@ export function followPlusDraft(projectId: string, next: FollowedDraft | null |
/** [저장] 차례 — 사용자 초안이 있으면 정본에 씀 · 없으면(follow 만 쓴 초안 포함 · 459) 저장분만 다시 받음
* (노선 저장이 좌표로 옮겼을 수 있음). */
export async function flushPlusStations(projectId: string): Promise<void> {
const part = plusStationsPart(projectId);
const list = part.body
? await request(projectId, { method: "PUT", body: JSON.stringify(part.body) })
: await request(projectId);
await part.after?.({ chainages: list });
}
/** 한 본문 [저장] 조각(472-6) — 사용자 초안이 있으면 `{chainages}` · 없으면(follow 만 쓴 초안 포함 · 459) null ·
* 저장 뒤 저장분을 받은 목록(없으면 다시 받음)으로 · 초안 비움. */
export function plusStationsPart(projectId: string): SavePart {
const auto = followedOnly.get(projectId);
followedOnly.delete(projectId);
beforeRoute.delete(projectId);
@@ -133,11 +144,15 @@ export async function flushPlusStations(projectId: string): Promise<void> {
if (kept && auto === JSON.stringify(kept))
useDesignStore(projectId, null).set("plusStations", null);
const draft = readPlusDraft(projectId);
const list = draft
? await request(projectId, { method: "PUT", body: JSON.stringify({ chainages: draft }) })
: await request(projectId);
saved.set(projectId, list);
loaded.set(projectId, Promise.resolve());
if (draft) useDesignStore(projectId, null).set("plusStations", null);
else notify(projectId);
return {
body: draft ? { chainages: draft } : null,
after: async (response) => {
const given = response?.chainages;
const list = Array.isArray(given) ? given.map(Number) : await request(projectId);
saved.set(projectId, list);
loaded.set(projectId, Promise.resolve());
if (draft) useDesignStore(projectId, null).set("plusStations", null);
else notify(projectId);
},
};
}
+16
View File
@@ -24,6 +24,7 @@ import {
type StructureDefinition,
withDefinition,
} from "./logic/B05_Profile_UI_Structures_Define";
import type { SavePart } from "../A00_Common/design/design_save_api";
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
export type StructurePlacement = "point" | "interval" | "site";
@@ -393,3 +394,18 @@ export async function flushPendingStructures(projectId: string): Promise<void> {
await saveStructures(projectId, stored.revision, pending ?? stored.structures); // 고침 없음 = 판 도장만
writePendingStructures(projectId, null);
}
/** 한 본문 [저장] 조각(472-6) — 초안(없고 정의 판이 낡았으면 저장분 = 판 도장) · 판번호는 지금 서버 값 ·
* 초안도 낡음도 없으면 null · 저장 뒤 초안 비움. `list` = 보기가 쥔 목록(초안 없을 때 · 구조물 보기). */
export async function structuresPart(
projectId: string,
list: StructureInstance[] | null = null,
): Promise<SavePart | null> {
const pending = readPendingStructures(projectId);
if (!pending && !list && !definitionStale(projectId)) return null;
const stored = await fetchStructures(projectId);
return {
body: { base_revision: stored.revision, structures: pending ?? list ?? stored.structures },
after: () => writePendingStructures(projectId, null),
};
}
+13 -11
View File
@@ -7,7 +7,7 @@
* 조작은 [저장]·[확정]에서만 정본으로 나간다). [초기화]는 한 벌(`design_reset`)이 한다.
* ========================================================================== */
import { flushRouteParts, type RouteLatestResponse } from "./B05_Profile_Api_Fetch";
import type { RouteLatestResponse } from "./B05_Profile_Api_Fetch";
import type { DesignViewSave } from "../A00_Common/design/design_shared";
import { pageRouteId } from "../A00_Common/design/design_page_route";
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
@@ -41,16 +41,18 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave {
const projectId = ctx.projectId;
return {
routeId: () => (ctx.routeReady() ? pageRouteId(projectId) : null), // 노선 id 한 칸(386-2b)
steps: {
// 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 초안을 남기고 던져
// 저장 한 줄기가 이 차례에서 멈춘다(443).
structures: () => ctx.bridge().saveStructuresIfDirty(),
// 상단측 + 비정규 측점 횡단 맞춤 — 기본 창구와 같은 본문(463 · 종단 보기를 안 열어도 같은 요청).
// 하위 실패 = 던짐 · 성공 = 상단측 세션 비움(443) · 메모리 값도 비움(다시 안 보냄).
uphill: async () => {
await flushRouteParts(projectId);
ctx.uphillOverrides.clear();
},
parts: {
// 구조물 — 보낼 것 = 세션 초안 ?? 이 보기 목록 · 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다(472-6).
structures: () => ctx.bridge().structuresSavePart(),
// 상단측 + 비정규 측점 횡단 맞춤 — 기본 조각 본문 그대로(463) · 저장 뒤 세션(기본) · 메모리 값도 비움.
uphill: (base) =>
base && {
body: base.body,
after: async (response) => {
await base.after?.(response);
ctx.uphillOverrides.clear();
},
},
},
// 횡단 본문은 늘 횡단 보기 몫 — [저장]이 숨긴 채라도 세움(386-6 · 옛 캐시 패치 길 지움)
// 공유 캐시는 한 벌(`design_shared`)이 저장 끝에 한 번 비움 — 여기서는 새 자료를 다시 읽어 종단 ·
@@ -21,6 +21,8 @@ import {
readPendingStructures,
writePendingStructures,
StructureConflictError,
structuresPart,
type StructureSaveResponse,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
@@ -30,6 +32,7 @@ import { listPick } from "../common_util/common_util_structure_catalog";
import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids";
import { pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data";
import type { FacilityLane } from "../A00_Common/design/design_facility_lane";
import type { SavePart } from "../A00_Common/design/design_save_api";
export interface StructuresBridgeDeps {
projectId: string;
@@ -117,6 +120,30 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
await run;
}
/** 한 본문 [저장] 조각(472-6) — 보낼 것 = 세션 초안 ?? 이 보기 목록(`saveStructuresIfDirty` 와 같음) ·
* 저장 뒤 판번호 · 서버 목록(새 식별자)으로 화면을 맞추고 뒤 단계 표시 실패를 알림. */
async function structuresSavePart(): Promise<SavePart | null> {
if (deps.isRestoring() || (readPending() === null && !definitionStale(deps.projectId)))
return null;
const part = await structuresPart(deps.projectId, readPending() ?? ownStructures);
if (!part) return null;
return {
body: part.body,
after: async (response) => {
part.after?.(response);
const saved = response as unknown as StructureSaveResponse | undefined;
if (saved) structureRevision = saved.revision;
await refreshStructuresFromServer();
if (saved?.needs_downstream_invalidation && !saved.invalidated_downstream)
showToast(
"구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " +
"B06을 다시 실행해 주세요.",
"error",
);
},
};
}
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
async function refreshStructuresFromServer(): Promise<boolean> {
const stored = await fetchStructures(deps.projectId).catch(() => null);
@@ -262,6 +289,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
deps.panel().structures.setStructures(ownStructures);
},
saveStructuresIfDirty,
structuresSavePart,
/** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */
refreshFromServer: refreshStructuresFromServer,
/** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */
@@ -30,6 +30,7 @@ import {
type StateName,
} from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import type { SavePart } from "../A00_Common/design/design_save_api";
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로.
* 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */
@@ -92,6 +93,14 @@ export async function flushCulvertOptions(
writePending(sessionKey, {});
}
/** 한 본문 [저장] 조각(472-6) — 세션 조각 {누가거리 0.01 글: 패치}(서버가 저장된 관 위에 얹음) · 없으면 null ·
* 저장 뒤 세션 비움. */
export function culvertOptionsPart(sessionKey: string | null): SavePart | null {
const pending = readPending(sessionKey);
if (!Object.keys(pending).length) return null;
return { body: pending, after: () => writePending(sessionKey, {}) };
}
/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */
export const STRUCTURE_ADJUST_KEYS = [
"inlet_structure",
+21 -9
View File
@@ -121,10 +121,7 @@ export async function confirmSections(
/** 프론트에서 계산한 유토곡선 결과. 확정 시점에만 영구 저장한다. */
massHaul?: Record<string, unknown>,
): Promise<SectionConfirmResponse> {
const body: Record<string, unknown> = {};
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
if (crossPatches?.length) body.cross_patches = crossPatches;
if (massHaul) body.mass_haul = massHaul;
const body = sectionConfirmBody(standardCrossSection, crossPatches, massHaul);
return warnUnrecomputed(
requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/confirm`, {
method: "POST",
@@ -139,6 +136,25 @@ async function warnUnrecomputed(
request: Promise<SectionConfirmResponse>,
): Promise<SectionConfirmResponse> {
const result = await request;
warnSectionSave(result);
return result;
}
/** 횡단 저장 본문(저장 · 확정 · 한 본문 [저장] 조각 공통). */
export function sectionConfirmBody(
standardCrossSection?: StandardCrossSection,
crossPatches?: CrossSectionPatch[],
massHaul?: Record<string, unknown>,
): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
if (crossPatches?.length) body.cross_patches = crossPatches;
if (massHaul) body.mass_haul = massHaul;
return body;
}
/** 횡단 저장 응답의 경고 — 재계산 실패 · 서버가 버린 고침 · 측점 맞춤 실패(한 본문 [저장]도 부름). */
export function warnSectionSave(result: Partial<SectionConfirmResponse>): void {
const dropped = [
...new Set([
...(result.dropped_patch_chainages ?? []),
@@ -163,7 +179,6 @@ async function warnUnrecomputed(
if (result.recomputed === false) showToast(L("B06_Recompute_Failed"), "warning", 8000);
if (lost.length) showToast(lost.join(" "), "warning", 8000);
});
return result;
}
/**
@@ -177,10 +192,7 @@ export async function saveSections(
crossPatches?: CrossSectionPatch[],
massHaul?: Record<string, unknown>,
): Promise<SectionConfirmResponse> {
const body: Record<string, unknown> = {};
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
if (crossPatches?.length) body.cross_patches = crossPatches;
if (massHaul) body.mass_haul = massHaul;
const body = sectionConfirmBody(standardCrossSection, crossPatches, massHaul);
return warnUnrecomputed(
requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/save`, {
method: "POST",
+18 -14
View File
@@ -8,8 +8,8 @@
* ========================================================================== */
import {
confirmSections,
saveSections,
sectionConfirmBody,
warnSectionSave,
type CrossSectionPatch,
type SectionContextResponse,
type SectionDetailResponse,
@@ -411,23 +411,27 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave {
// 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측).
prepare: () => ctx.reconcileDesigns(),
// 조정창 구간값은 조정창 저장기가 낸다 — 실패하면 알리고 저장을 멈춘다(예전 그대로).
steps: { culvertOptions: () => ctx.flushCulvertOptions() },
cross: async (confirm) => {
// 관 옵션(조정창 구간값) = 기본 조각(세션 `culvertopt`) — 서버가 저장된 관 위에 얹음(472-6)
crossPart: async () => {
const routeId = ctx.routeId();
if (routeId === null || !ctx.projectId) return;
if (routeId === null || !ctx.projectId) return null;
const projectId = ctx.projectId;
const edits = collectSectionEdits(ctx);
// 바탕 = 캐시 설계의 사용자 칸(측점마다 통째 · 옮긴 측점은 지반 등 입력까지) + 세션 고침(445 H2 · H3 · M2)
// 가게는 늦게 불러옴(가게 → 다시 셈 → 이 파일 순환 없음)
const { cachedCrossPatches } = await import("./B06_Section_Section_Store");
const patches = cachedCrossPatches(ctx.projectId, routeId, edits.crossPatches);
await (confirm ? confirmSections : saveSections)(
ctx.projectId,
routeId,
ctx.standardValues(),
patches.length ? patches : undefined,
edits.massHaul,
);
clearState("kindreset", ctx.projectId); // 비울 자리를 보냈음(318-4)
const patches = cachedCrossPatches(projectId, routeId, edits.crossPatches);
return {
body: sectionConfirmBody(
ctx.standardValues(),
patches.length ? patches : undefined,
edits.massHaul,
),
after: (response) => {
clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4)
if (response) warnSectionSave(response); // 버린 고침 · 측점 맞춤(445 M5)
},
};
},
done: async () => {
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
@@ -19,12 +19,15 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 새로고침 | 세션 남음 · 메모리 전부 잃음 | 세션 상세로 먼저 섬 → 서버 정본과 비교(`checkSessionCanon`) → 다르면 갈아씀 · 노선 초안 있으면 hold · 관 · 구조물 · 추가 측점 초안 있으면 측점 냄 · 없으면 조용히 열고 가게 측점 = 상세 측점 | 상세 GET(정본 확인) | — | — | `B06_Section_Section_Store.ts:117-135` · `design_pipeline_page.ts:528-545` · `design_pipeline_page_publish.ts` opened · shown |
| 떠남 [취소] | 없음 — 페이지 그대로 | — | — | — | — | `design_leave.ts:95-123` |
| 떠남 [폐기] | 세션 고침 칸 지움(모든 노선 · `NOT_EDITS` · B08 · B09 칸 뺌) → 떠남 = dispose | dispose = 묶음 · 시설 줄 · 페이지 자료(`forgetPageData`) · 상세 메모리(`forgetSectionMemory`) 버림 · 다시 열면 새로고침 길 | — | — | — | `design_leave.ts:27-42 · 85-87` · `design_page.ts:286-295` · `B06_Section_Section_Store.ts:424-428` |
| [저장] | — | 앞 단계 `prepare` = 전 측점 강제 다시 셈(서버가 면적 버림) · 화면 잠금 | 아래 차례 표 · 서버 정본 다시 셈 | 끝 = `invalidateSectionDetail` · 두 보기가 새 상세 한 객체 받음 · 노선 바뀜 = 페이지 다시 세움 | — | `design_shared.ts:395-448` · `design_save.ts:237-324` |
| [저장] | — | 앞 단계 `prepare` = 전 측점 강제 다시 셈(서버가 면적 버림) · 화면 잠금 | 노선 안 바뀜 = 조각 한 본문 `POST /design/save`(서버가 아래 차례 · 전부 아니면 없음) · 노선 바뀜 = 아래 차례 표(옛 길) · 서버 정본 다시 셈 | 끝 = `invalidateSectionDetail` · 두 보기가 새 상세 한 객체 받음 · 노선 바뀜 = 페이지 다시 세움 | — | `design_shared.ts:395-448` · `design_save.ts:237-324` |
| [확정] | — | [저장]과 같음 | [저장] + 빈 측점 기본 설계 · 단계 완료 · 측구 쪽을 B05 종단 파일로 되씀 | 끝 = `invalidateSectionDetail` → B07 로 넘어감 · 노선이 바뀐 [확정] = 노선만 저장 · 다시 [확정] 알림 | — | `design_shared.ts:426-432` · `design_save.ts:303-310` · `B06_Section_Router_Confirm.py:368-496` |
| [초기화] | — | 상세 비움 · 새로고침(메모리 전부 버림) | `/route/reset` | DB 노선 행 지우고 초기값으로 · 파일 트리 복원 · 세션 초안 전부 지움(B08 · B09 뺌) | — | `design_reset.ts:30-62` · `B05_Profile_Router_Lifecycle.py:234-390` · `common_util_initial_snapshot.py:38-54 · 266-327` |
## [저장] 차례(`DESIGN_SAVE_ORDER` `design_save.ts:58-68`)
- 노선 안 바뀐 [저장] · [확정](472-6 6-A) — 브라우저가 차례마다 조각(본문 · 저장 뒤 정리)을 모아 한 요청 (`design_save.ts` saveOneBody · `design_save_api.ts`) · 서버 `B06_Section_Router_DesignSave.py` 가 같은 차례로 아래 창구 함수를 부름 · 하나라도 실패 = DB 행 · storage 되돌림(`common_util_save_guard.py`) · 차례 이름째 알림 · 같은 프로젝트 저장 겹침 = 뒤 요청 기다림.
- 노선 바뀐 [저장] = 아래 표 차례마다 요청(6-B 전).
| 차례 | 기본 창구 | 서버 | 노선 바뀐 [저장] | 위치(파일:줄) |
|---|---|---|---|---|
| route | `pendingRouteSave` → `saveRouteDraft` | `/route/replan` · 관 · 구조물 따라감 · 노선 밖 알림 | 새 노선 id · 관 · 구조물 초안 다시 얹음 | `design_save.ts:255-274` |
+1 -1
View File
@@ -49,7 +49,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| 내용 | 자리(파일:줄) | 상태 |
|---|---|---|
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 |
| [저장] 9 차례 · 차례마다 따로 커밋 — 실패 = 앞 차례만 저장 | `design_save.ts:253-299` | 472-6 |
| 노선 바뀐 [저장]만 9 차례 · 차례마다 따로 커밋 — 실패 = 앞 차례만 저장(노선 안 바뀐 저장 = 한 본문 · 전부 아니면 없음) | `design_save.ts:253-299` · `B06_Section_Router_DesignSave.py` | 472-6 6-B |
| `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 |
| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠 | `design_save.ts:187-188` | 추정 |
| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정 메모리 맵 · B05 상단측 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore | 관 · 구조물 = `test_472_5_shop_owner` · 나머지 추정 |
+7 -5
View File
@@ -31,6 +31,7 @@ globalThis.window = {
const REAL = new Set(
[
"A00_Common/design/design_save.ts",
"A00_Common/design/design_save_api.ts",
"A00_Common/design/design_page_route.ts",
"A00_Common/b_page_state.ts",
"B05_Profile/B05_Profile_Api_Fetch.ts",
@@ -86,6 +87,7 @@ globalThis.fetch = async (url, init) => {
if (url.includes("/surface/confirmed")) reply = { model_id: 7 };
else if (url.includes("/route/latest")) reply = LATEST;
else if (url.includes("/route/confirm")) reply = { failed_parts: [] };
else if (url.includes("/design/save")) reply = { status: "success", steps: { uphill: {} } };
return { ok: true, status: 200, json: async () => reply };
};
@@ -96,14 +98,14 @@ function seed(withLatest) {
state.writeState("uphill", { "40.000": "right" }, P);
}
async function run(steps) {
async function run(parts) {
lines = [];
const failed = [];
const ok = await save.saveDesign({
projectId: P,
routeId: () => 3,
steps,
cross: async () => undefined,
parts,
crossPart: async () => null,
failed: (error) => failed.push(String(error?.message ?? error)),
});
return { ok, failed, lines, uphillAfter: state.readState("uphill", P) };
@@ -125,10 +127,10 @@ async function run(steps) {
latest: () => LATEST,
confirmedSurface: () => ({ model_id: 7 }),
routeReady: () => true,
bridge: () => ({ saveStructuresIfDirty: async () => undefined }),
bridge: () => ({ structuresSavePart: async () => null }),
uphillOverrides: memory,
});
out.open = await run({ uphill: parts.steps.uphill });
out.open = await run({ uphill: parts.parts.uphill });
out.openMemoryAfter = memory.size;
// ③ 새로고침해 B06 로 바로 — 노선 캐시 없음 = 서버에서 한 번 읽고 같은 본문
+39 -12
View File
@@ -41,6 +41,28 @@ const STUBS = {
fetchSectionDetail: async () => (calls.push(`fetch:${server}`), detail()),
saveSections: async () => calls.push("cross:save"),
confirmSections: async () => calls.push("cross:confirm"),
sectionConfirmBody: () => ({}),
warnSectionSave: () => undefined,
},
};
// 472-6 한 본문 창구 — 서버가 차례대로 정본을 쓰는 동안 가게가 바뀜(바뀜 신호 = 저장 도중) → 횡단 저장
let duringSave = async () => undefined;
STUBS[path.join(ROOT, "A00_Common", "design", "design_save_api.ts")] = {
SAVE_PART_KEYS: [
"settings",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
"cross",
],
DesignSaveError: class extends Error {},
postDesignSave: async (_p, _r, confirm, parts) => {
await duringSave();
if (parts.cross?.body) calls.push(confirm ? "cross:confirm" : "cross:save");
return { waited: false, steps: {} };
},
};
@@ -74,7 +96,11 @@ STUBS["@util/common_util_station_key"] = loadTs(
);
// 상단측 차례 본문(463 · 지표 조건 · 확정 지표면 GET · 확정 POST) — 이 시험은 횡단 뒤처리만 봄
const B05_FETCH = path.join(ROOT, "B05_Profile", "B05_Profile_Api_Fetch.ts");
STUBS[B05_FETCH] = { ...loadTs(B05_FETCH), flushRouteParts: async () => undefined };
STUBS[B05_FETCH] = {
...loadTs(B05_FETCH),
flushRouteParts: async () => undefined,
routePartsBody: async () => ({}),
};
const { saveDesign, savingDesign } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_save.ts"),
);
@@ -117,21 +143,22 @@ const { invalidateSectionDetail, loadSectionDetail } = loadTs(
const parts = crossSaveParts(ctx);
calls.length = 0;
const savingSeen = [];
// 관 · 구조물 초안이 정본으로 나가며 가게가 바뀜 → 바뀜 신호(저장 도중)
duringSave = async () => {
server = "새 자리 4+8 · 47+0";
savingSeen.push(savingDesign(P));
await refresh();
};
const ok = await saveDesign({
projectId: P,
routeId: () => R,
steps: {
// 관 · 구조물 초안이 정본으로 나가며 가게가 바뀜 → 바뀜 신호(저장 도중)
structures: async () => {
server = "새 자리 4+8 · 47+0";
savingSeen.push(savingDesign(P));
await refresh();
},
// 추가 측점(368-2) — 초안 없음 · 이 시험은 안 봄(진짜는 서버 GET)
plusStations: async () => undefined,
...parts.steps,
parts: {
// 구조물 · 추가 측점 — 초안 없음 · 이 시험은 안 봄(진짜는 서버 GET)
structures: () => null,
plusStations: () => null,
...parts.parts,
},
cross: (confirm) => parts.cross(confirm),
crossPart: (confirm) => parts.crossPart(confirm),
// 뒤처리 = 한 벌(`design_shared` saveSharedDesign done) 차례 그대로 — 공용 캐시를 한 번 비운 뒤 보기 몫(416 M1 ·
// 횡단 보기 done 은 비우지 않고 다시 그리기만)
done: async () => {
+48 -4
View File
@@ -71,12 +71,17 @@ STUBS["B04_PreProcess_Api_Fetch"] = {
};
STUBS["B05_Profile_Api_Structures"] = {
flushPendingStructures: async () => failing("structures"),
structuresPart: async () => null,
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459) 안 돎
};
STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined };
STUBS["B05_Profile_Api_PlusStations"] = {
flushPlusStations: async () => undefined,
plusStationsPart: () => ({ body: null }),
};
STUBS["B05_Profile_Api_Fetch"] = {
flushUphillOverrides: async () => undefined,
flushRouteParts: async () => undefined, // 상단측 차례(463)
routePartsBody: async () => ({}),
saveProfileAlignment: async (_projectId, routeId, draft) => {
calls.push(["ALIGN", routeId, draft]);
return {};
@@ -87,7 +92,38 @@ STUBS["B05_Profile_UI_Profile_Edit"] = {
markAlignmentSaved: (routeId) => alignment.delete(routeId),
writeAlignmentDraft: (routeId, edits) => alignment.set(routeId, edits),
};
STUBS["B06_Section_Api_Culvert_Options"] = { flushCulvertOptions: async () => undefined };
STUBS["B06_Section_Api_Culvert_Options"] = {
flushCulvertOptions: async () => undefined,
culvertOptionsPart: () => null,
};
// 472-6 한 본문 창구 — 관 칸 · 계획고 칸만 적음(옛 PUT 줄과 같은 꼴)
STUBS["design_save_api"] = {
SAVE_PART_KEYS: [
"settings",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
"cross",
],
DesignSaveError: class extends Error {},
postDesignSave: async (_projectId, routeId, confirm, parts) => {
const pipes = parts.pipes?.body;
calls.push([
"SAVE",
pipes === undefined || pipes === null ? "none" : (pipes.points ?? null),
confirm,
]);
const align = parts.alignment?.body;
if (align) {
const { route_id: _id, ...draft } = align;
calls.push(["ALIGN", routeId, draft]);
}
return { waited: false, steps: {} };
},
};
STUBS["B05_Profile_UI_RouteEdit_Apply"] = {
pendingRouteSave: async () => routeBody,
saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }),
@@ -114,8 +150,16 @@ async function once({ route = false, draft = null, confirm = false, fail = null,
projectId: P,
routeId: () => routeId,
// 화면 몫 = 배수판 다시 받기(PUT 없음) — 433-7 뒤 design_shared 와 같음
steps: { pipes: async () => void calls.push(["RELOAD"]) },
cross: async () => void calls.push(["CROSS"]),
parts: {
pipes: (base) => ({
body: base?.body ?? null,
after: async (response) => {
await base?.after?.(response);
calls.push(["RELOAD"]);
},
}),
},
crossPart: async () => ({ body: {} }),
failed: (error) => (failure = String(error && error.message ? error.message : error)),
},
{ confirm },
+58 -5
View File
@@ -44,6 +44,33 @@ const routeStub = {
routeSaveSummary: (saved, outside) => (calls.push(`summary:${saved.route_id}:${outside}`), ""),
rebuildAfterRouteSave: async () => calls.push("rebuild"),
};
class DesignSaveError extends Error {
constructor(message, step, restored) {
super(message);
this.step = step;
this.restored = restored;
}
}
const SAVE_PART_KEYS = [
"settings",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
"cross",
];
const saveApiStub = {
SAVE_PART_KEYS,
DesignSaveError,
postDesignSave: async (_p, routeId, confirm, parts) => {
const sent = SAVE_PART_KEYS.filter((key) => parts[key] && parts[key].body != null);
calls.push(`post:${routeId}:${confirm}:${sent.join(",")}`);
if (failStructures) throw new DesignSaveError("structures 실패", "structures", true);
return { waited: false, steps: { alignment: { dropped_edit_chainages: [] } } };
},
};
const STUBS = {
"../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": routeStub,
"@ui/ui_template_elements": {
@@ -53,6 +80,7 @@ const STUBS = {
},
"../../B05_Profile/B05_Profile_Api_Pipes_Draft": {
flushPendingPipes: () => record("pipes")(),
pipesPart: () => ({ body: { points: [] }, after: () => calls.push("pipes:after") }),
readPendingPipes: () => null, // 관 초안 없음 — 노선 저장 뒤 얹기(349-0u)는 안 돎
writePendingPipes: () => undefined,
},
@@ -65,24 +93,39 @@ const STUBS = {
},
"../../B05_Profile/B05_Profile_Api_Structures": {
flushPendingStructures: () => record("structures", failStructures)(),
structuresPart: async () => ({
body: { base_revision: 0, structures: [] },
after: () => calls.push("structures:after"),
}),
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459)는 안 돎
writePendingStructures: () => undefined,
fetchStructures: async () => ({ structures: [] }),
},
"../../B05_Profile/B05_Profile_Api_PlusStations": {
flushPlusStations: () => record("plusStations")(),
plusStationsPart: () => ({ body: null, after: () => calls.push("plusStations:after") }),
},
"../../B05_Profile/B05_Profile_Api_Fetch": {
flushUphillOverrides: () => record("uphill")(),
flushRouteParts: () => record("uphill")(), // 상단측 차례 = 종단 보기 몫과 같은 본문(463)
routePartsBody: async () => ({ irregular_stations: [] }),
saveProfileAlignment: async (_p, routeId, edits) =>
calls.push(`alignment:${routeId}:${JSON.stringify(edits.station_offsets)}`),
},
"../../B06_Section/B06_Section_Api_Culvert_Options": {
flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`),
culvertOptionsPart: (key) => ({
body: { "10.00": {} },
after: () => calls.push(`culvert:after:${key}`),
}),
},
// 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings)
"../../B05_Profile/B05_Profile_Api_PageSettings": { flushPageSettings: async () => undefined },
"../../B05_Profile/B05_Profile_Api_PageSettings": {
flushPageSettings: async () => undefined,
pageSettingsPart: () => null,
},
// 472-6 한 본문 창구 — 보낸 칸만 적음 · 구조물 실패 = 서버가 그 차례에서 되돌림
"./design_save_api": saveApiStub,
};
const cache = new Map();
@@ -160,8 +203,19 @@ async function run() {
projectId: "p1",
routeId: () => 7,
prepare: record("prepare"),
steps: { pipes: record("pipes:screen") },
cross: async (confirm) => calls.push(`cross:${confirm}`),
parts: {
pipes: (base) => ({
body: base.body,
after: async (response) => {
await base.after(response);
calls.push("pipes:screen");
},
}),
},
crossPart: async (confirm) => ({
body: { confirm },
after: () => calls.push(`cross:after:${confirm}`),
}),
done: async (confirm) => calls.push(`done:${confirm}`),
failed: () => calls.push("failed"),
});
@@ -177,8 +231,7 @@ async function run() {
{
projectId: "p1",
routeId: () => 7,
steps: { uphill: record("uphill:screen") },
cross: async (confirm) => calls.push(`cross:${confirm}`),
crossPart: async (confirm) => ({ body: { confirm } }),
failed: (error) => (calls.push("failed"), (out.tolerantError = error.message)),
},
{ confirm: true },
@@ -30,8 +30,11 @@ def test_save_path_and_merge_kept():
# 저장 길(세션 → 저장 몸통)과 B06 이 부르는 종단 병합 함수는 그대로
page = _read("A00_Common", "design", "design_view_profile.ts")
assert "persistUphillOverrides" in page
# 상단측 본문 = 종단 보기 몫 · 기본 창구 한 함수(463)
assert "flushRouteParts(projectId)" in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts")
# 상단측 본문 = 기본 조각 한 벌(463 · 472-6 종단 보기 몫은 그 본문 그대로)
assert "body: base.body," in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts")
assert "body: await routePartsBody(projectId)," in _read(
"A00_Common", "design", "design_save.ts"
)
assert "uphill_overrides: uphillOverridesOf(projectId)," in _read(
"B05_Profile", "B05_Profile_Api_Fetch.ts"
)
+7 -4
View File
@@ -41,9 +41,10 @@ def got() -> dict:
def _confirm_body(lines: list[str]) -> dict:
posts = [line for line in lines if line.startswith("POST ") and "/route/confirm" in line]
"""상단측 차례 본문 — 472-6 부터 한 본문 창구(`/design/save`)의 `uphill` 칸."""
posts = [line for line in lines if line.startswith("POST ") and "/design/save" in line]
assert len(posts) == 1, lines
return json.loads(posts[0].split(" ", 2)[2])
return json.loads(posts[0].split(" ", 2)[2])["uphill"]
def test_종단_보기를_열든_안_열든_같은_요청(got):
@@ -79,6 +80,8 @@ def test_서버는_그_본문이면_비정규_측점_횡단을_맞춤(got):
def test_종단_보기_몫은_기본_창구_함수를_부름():
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8")
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8")
assert "await flushRouteParts(projectId);" in actions
assert "saveRouteParts(" not in actions
# 472-6 — 종단 보기 몫은 기본 조각 본문 그대로(메모리만 더 비움) · 노선 바뀐 저장은 옛 창구
assert "uphill: (base) =>" in actions and "body: base.body," in actions
assert "saveRouteParts(" not in actions and "flushRouteParts" not in actions
assert "body: await routePartsBody(projectId)," in save
assert "return () => flushRouteParts(projectId);" in save
+1 -1
View File
@@ -243,7 +243,7 @@ def test_wiring_sources() -> None:
assert "forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id" in events[live:]
persist = _src("B06_Section", "B06_Section_UI_Page_Persist.ts")
assert "patchFor(at).structure_reset = true" in persist
assert 'clearState("kindreset", ctx.projectId)' in persist
assert 'clearState("kindreset", projectId)' in persist # 472-6 횡단 조각 저장 뒤
store = _src("B06_Section", "B06_Section_Section_Store.ts")
assert "crossPatchesFromCache(detail, kindChangedChainages(projectId), savedAt)" in store
view = _src("A00_Common", "design", "design_view_cross.ts")
+4 -3
View File
@@ -28,13 +28,14 @@ def test_저장도_횡단_보기를_세움():
body = _save_body()
assert 'await shared.ensureView("cross");' in body
assert 'if (confirm) await shared.ensureView("cross")' not in body
assert "cross: crossBody," in body
# 472-6 — 횡단 본문 = 횡단 보기 조각(`crossPart`) · 한 본문 창구에 실림
assert " crossPart," in body
assert "profileSave?.cross" not in body
def test_못_세우면_실패_알림():
body = _save_body()
assert "if (!crossBody) {" in body
assert "if (!crossPart) {" in body
assert '"B06_Profile_Save_Failed"' in body and '"B06_Profile_Confirm_Failed"' in body
@@ -43,4 +44,4 @@ def test_종단_보기_몫엔_횡단_본문_없음():
start = actions.index("export function profileSaveParts(")
parts = actions[start:]
assert "saveCachedCrossPatches" not in actions
assert "\n cross: " not in parts
assert "\n cross: " not in parts and "crossPart" not in parts
+2 -2
View File
@@ -72,8 +72,8 @@ def test_확정_하위_실패면_던짐(out):
def test_종단_보기_상단측_차례도_같은_창구():
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8")
# 기본 창구와 같은 함수(463) — 상단측은 세션에서 읽음(메모리 옛 값 안 보냄)
assert "await flushRouteParts(projectId);" in actions
# 기본 창구와 같은 본문(463 · 472-6 기본 조각) — 상단측은 세션에서 읽음(메모리 옛 값 안 보냄)
assert "body: base.body," in actions and "flushRouteParts" not in actions
fetch = (ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts").read_text("utf-8")
assert "uphill_overrides: uphillOverridesOf(projectId)," in fetch
assert "confirmRoute(" not in actions
+3 -1
View File
@@ -243,7 +243,9 @@ def test_H5_노선_저장_뒤_본문_M7_kindreset_옮김():
for needle in ("crossDesignChoices(", "readRockBoundarySession(", "readCutSlopeSession("):
assert needle in overlay, needle
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "cachedCrossPatches(ctx.projectId, routeId, edits.crossPatches)" in persist
assert (
"cachedCrossPatches(projectId, routeId, edits.crossPatches)" in persist
) # 472-6 횡단 조각
keys = (ROOT / "B06_Section" / "B06_Section_Station_Keys.ts").read_text(encoding="utf-8")
carry = keys[keys.index("export function carryChainageStates") :]
assert 'writeState("kindreset", rekey("revetx", resets), projectId)' in carry
+9 -8
View File
@@ -40,31 +40,32 @@ def out() -> dict:
return json.loads(proc.stdout)
def _puts(case: dict) -> list:
return [call for call in case["calls"] if call[0] == "PUT"]
def _saves(case: dict) -> list:
"""472-6 — 노선 안 바뀐 저장 = 한 본문 창구 한 번 · ["SAVE", 관 칸(points · "none"), 확정]."""
return [call for call in case["calls"] if call[0] == "SAVE"]
def test_관_PUT_은_초안_한_번(out):
case = out["draft"]
assert case["ok"] and _puts(case) == [
["PUT", [{"chainage_m": 15, "source": "user", "id": "p1"}], False]
assert case["ok"] and _saves(case) == [
["SAVE", [{"chainage_m": 15, "source": "user", "id": "p1"}], False]
]
assert case["calls"].index(["RELOAD"]) > case["calls"].index(
_puts(case)[0]
_saves(case)[0]
) # 화면은 뒤에 받기만
assert case["left"] is None
def test_초안_없으면_PUT_없음(out):
assert out["noDraft"]["ok"] and _puts(out["noDraft"]) == []
assert out["noDraft"]["ok"] and _saves(out["noDraft"]) == [["SAVE", "none", False]]
def test_확정은_칸_없이_다시_셈(out):
assert _puts(out["confirmNoDraft"]) == [["PUT", None, True]]
assert _saves(out["confirmNoDraft"]) == [["SAVE", None, True]]
def test_빈_초안은_다_지움(out):
assert _puts(out["emptyDraft"]) == [["PUT", [], False]]
assert _saves(out["emptyDraft"]) == [["SAVE", [], False]]
def test_노선_저장_뒤_실패_초안_새_노선(out):
+7 -3
View File
@@ -75,10 +75,14 @@ def test_저장이_창구를_다섯_다_거친다():
"flushPendingStructures(",
):
assert token in save, f"{token} 가 저장 한 줄기에서 사라짐"
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "ctx.flushCulvertOptions()" in persist, "B06 조정창 구간값이 저장에서 빠짐"
# 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게).
# 472-6 — 노선 안 바뀐 저장은 한 본문 조각(조정창 구간값 = 세션 `culvertopt`) · 노선 바뀐
# 저장은 위 창구 차례 그대로
assert 'culvertOptionsPart(stateKey("culvertopt", projectId, routeId))' in save, (
"B06 조정창 구간값이 한 본문 저장에서 빠짐"
)
# 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨) · 한 본문은 서버가 차례 이름째 되돌림
assert "throw stepFailed(step, error)" in save, "실패한 차례에서 멈춤이 빠짐"
assert "STEP_LABEL[error.step]" in save, "한 본문 실패 차례 이름이 빠짐"
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
order = order[: order.index("]")]
names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
+6 -3
View File
@@ -32,12 +32,15 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None:
text = PERSIST.read_text(encoding="utf-8")
body = _body(text, "export function crossSaveParts")
assert "prepare: () => ctx.reconcileDesigns()" in body, "횡단 보기 몫이 재계산을 안 넘긴다"
assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖"
assert "collectSectionEdits(ctx)" in body[body.index("crossPart:") :], (
"면적 수집이 횡단 조각 밖"
)
shared = _body(SHARED.read_text(encoding="utf-8"), "export async function saveSharedDesign")
assert "prepare: crossSave?.prepare" in shared, "한 벌이 재계산을 저장 한 줄기에 안 넘긴다"
save = SAVE.read_text(encoding="utf-8")
run = _body(save, "export async function saveDesign", 4000)
assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), (
# 472-6 — 노선 안 바뀐 저장 = 한 본문(`saveOneBody`) · 재계산 뒤에 횡단 조각을 모음
run = _body(save, "async function saveOneBody", 4000)
assert run.index("ctx.prepare?.()") < run.index("ctx.crossPart(confirm)"), (
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
)
+16 -10
View File
@@ -60,17 +60,18 @@ def test_저장_차례(out):
"cross",
] # 346-3 노선 먼저
assert out["saveOk"] is True
# 472-6 — 노선 초안 없으면 조각을 모아 한 요청(서버 한 창구 · 전부 아니면 없음) ·
# 저장 뒤 조각 정리는 차례대로 · 본문 없는 조각(추가 측점 초안 없음)도 정리는 돎
assert out["saveCalls"] == [
"overlay:on",
"prepare",
"pipes",
"post:7:false:pipes,structures,alignment,culvertOptions,uphill,cross",
"pipes:after",
"pipes:screen",
"structures",
"plusStations",
'alignment:7:{"20.000":0.1}',
"culvert:aislo:draft:culvertopt:p1:7",
"uphill",
"cross:false",
"structures:after",
"plusStations:after",
"culvert:after:aislo:draft:culvertopt:p1:7",
"cross:after:false",
"done:false",
"overlay:off",
]
@@ -78,10 +79,15 @@ def test_저장_차례(out):
def test_구조물_실패는_멈추고_알림_노선없음(out):
"""429 ⑨ — 차례가 실패하면 뒤 차례(상단측 · 횡단)를 안 보냄 · 차례 이름째 알림."""
assert out["tolerant"] == ["overlay:on", "pipes", "structures", "failed", "overlay:off"]
"""429 ⑨ · 472-6 — 서버가 구조물 차례에서 실패 · 되돌림 → 정리 · done 없음 · 차례 이름째."""
assert out["tolerant"] == [
"overlay:on",
"post:7:true:pipes,structures,culvertOptions,uphill,cross",
"failed",
"overlay:off",
]
assert out["tolerantOk"] is False
assert out["tolerantError"].startswith("구조물 차례에서 멈췄습니다")
assert out["tolerantError"].startswith("구조물 차례에서 실패 — 아무것도 저장하지 않았습니다")
assert "structures 실패" in out["tolerantError"] # 까닭 그대로
assert out["noRouteSave"] is False and out["noRouteCalls"] == []