Merge remote-tracking branch 'origin/dev' into sub_laptop_4

This commit is contained in:
2026-10-19 11:09:20 +09:00
41 changed files with 1413 additions and 176 deletions
@@ -3,12 +3,12 @@
* 계산 묶음 관 · 구조물 · 추가 측점 먹이기(PLAN 415 · 429 ⑨) — 가게 초안이 바뀌면 묶음에 넣음(비면 저장분).
* · [저장] 도중 = 안 넣음(H2) — 초안이 비는 사이 열 때 읽은 옛 저장분으로 돌지 않게 · 끝나면 `resync`
* (저장 목록 다시 읽기 · 입력 맞춤) 한 번.
* · 되돌리기 · 다시하기 = 세션만 되쓰고 가게 알림이 없음 → 묶음 입력과 견줘 다르면 넣음(415-2 · 노선 초안은
* 노선 길이 맡음).
* · 되돌리기 · 다시하기 = 아래 되살림 훅 한 길 — 묶음 입력과 견줘 다르면 넣음(415-2 · 노선 초안은 노선 길이
* 맡음) · 그 사이 가게 알림(`announceDesignStore` · 472-5)은 구독이 받지 않음(두 번 안 넣음).
* 저장 창구는 늦게 불러옴(묶음 시험이 화면 모듈을 안 끌게).
* ========================================================================== */
import { onPageHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History";
import { onPageHistory, pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
import type { useDesignStore } from "./design_store";
import type { ChangeSource } from "./logic/design_pipeline_types";
@@ -57,7 +57,13 @@ export function feedFacilities(o: {
saving() ? afterSave() : o.feed(source, value);
const stops = [
...FACILITIES.map(({ source, key }) =>
o.store.subscribe(key, (value) => o.ready() && put(source, value ?? o.saved(key))),
o.store.subscribe(key, (value) => {
const now = value ?? o.saved(key);
// 묶음 입력과 같은 값 = 이미 받음(되살림 뒤 추가 측점 다시 알림 등) — 한 번만 넣음(472-5)
if (!o.ready() || pageReplaying() || JSON.stringify(now) === JSON.stringify(o.input(key)))
return;
put(source, now);
}),
),
onPageHistory({
restore: () => {
+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,
@@ -300,6 +304,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());
@@ -617,19 +643,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) {
+4 -3
View File
@@ -241,11 +241,12 @@ const listeners = new Map<string, Set<(value: unknown) => void>>();
* 노선을 모르면 `routeId = null` — 노선 칸(`alignmentDraft` · `sectionDetail`)은 비어 보이고
* 쓰기는 버려진다. 프로젝트를 모르면 프로젝트 칸이 그렇다.
*/
/** 가게를 안 거친 세션 되씀(되돌리기 · 다시하기) 뒤 — 프로젝트 목록 칸을 듣는 쪽을 지금 값으로 부름
* (472-5 · 관 · 구조물 사본이 옛 값으로 남아 다음 조작이 되돌림을 덮던 흠). */
/** 가게를 안 거친 세션 되씀(되돌리기 · 다시하기) 뒤 — 관 · 구조물 칸 듣는 쪽을 지금 값으로 한 번 부름
* (472-5 · 사본이 옛 값으로 남아 다음 조작이 되돌림을 덮던 흠). 추가 측점 · 페이지 설정은 자기 되살림 훅이
* 알림(`_Api_PlusStations` · `_Api_PageSettings`) · 계산 묶음은 되살림 훅 한 길(구독은 되돌리는 동안 안 받음). */
export function announceDesignStore(projectId: string): void {
const store = useDesignStore(projectId, null);
(["structures", "pipes", "plusStations"] as const).forEach((key) => {
(["structures", "pipes"] as const).forEach((key) => {
const id = slotOf(key, projectId, null).id;
const value = store.get(key);
if (id) listeners.get(`${key}|${id}`)?.forEach((fn) => fn(value));
+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,
/** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */
@@ -573,8 +573,16 @@ export function createStructuresSection(
},
getStructures: () => [...structures],
setPipeFacilities(pipes) {
const before = pipeFacilities;
pipeFacilities = pipes.map((pipe) => ({ ...pipe }));
followPipeList(panelContext, pipeFacilities);
// 열린 관 폼 — 값이 밖에서 바뀌면(되돌리기 · 다른 보기) 새 값으로 다시 엶 · 고치던 실시간 반영 · 임시 배치는
// 둠(472-5 · 옛 칸 값이 다음 조작에 되쓰이던 흠)
const at = selectedPipeChainage;
if (at === null || applyingLive || tempPipeChainage !== null) return;
const near = (pipe: PipeFacilityItem) => Math.abs(pipe.chainage_m - at) < PIPE_MATCH_M;
const now = pipeFacilities.find(near);
if (now && JSON.stringify(now) !== JSON.stringify(before.find(near))) loadPipeForm(now);
},
selectPipeByChainage(chainageM) {
if (chainageM !== null) callbacks.onReveal?.();
@@ -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",
@@ -0,0 +1,211 @@
"""B05 · B06 [저장] · [확정] 한 본문 창구(PLAN 472-6 6-A) — 노선 안 바뀐 저장.
브라우저가 세션 초안 · 사용자 칸만 한 본문으로 보내면 서버가 옛 차례 그대로 안쪽 창구를 부름:
페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 관 옵션 → 상단측(비정규 측점 맞춤) → 횡단
각 안쪽 창구가 정본을 다시 셈(브라우저 결과 안 받음 · 횡단 차례 끝 서버 재계산). 하나라도 실패하면
(오류 응답 · 측점 맞춤 실패 · 재계산 실패 · 하위 차례 실패) DB 행 · storage 를 시작 전으로 되돌리고
그 차례 이름 · 까닭을 돌려줌(`common_util_save_guard` · 브레인 A). 되돌림마저 실패하면 「복구 확인
필요」. 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`waited`).
노선 바뀐 저장(노선 초안)은 아직 옛 길(`/route/replan` 뒤 차례 · 6-B).
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Schema import (
PageSettingsRequest,
ProfileAlignmentSaveRequest,
RouteConfirmRequest,
)
from B05_Profile.B05_Profile_Structures_Schema import StructureSaveRequest
from B06_Section.B06_Section_Router_Stations import PlusStationsRequest
from B06_Section.B06_Section_Schema import SectionConfirmRequest
from common_util import common_util_save_guard as guard
from common_util.common_util_auth import get_optional_session
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Design Save"])
class DesignSaveRequest(BaseModel):
"""한 본문 — 칸이 없으면 그 차례를 건너뜀(관은 [확정]이면 칸 없어도 다시 셈)."""
model_config = ConfigDict(extra="forbid")
route_id: int
confirm: bool = False
settings: PageSettingsRequest | None = None
#: 관 PUT 본문(`{points}` · 빈 dict = 서버 저장분 그대로)
pipes: dict[str, Any] | None = None
structures: StructureSaveRequest | None = None
plus_stations: PlusStationsRequest | None = None
alignment: ProfileAlignmentSaveRequest | None = None
#: 관 옵션 조각 {누가거리 0.01 글: {키: 값}} — 저장된 관 위에 얹어 다시 씀
culvert_options: dict[str, dict[str, Any]] | None = None
uphill: RouteConfirmRequest | None = None
cross: SectionConfirmRequest | None = None
class StepFailed(Exception):
def __init__(self, step: str, message: str) -> None:
super().__init__(message)
self.step = step
def _body(response: Any) -> dict[str, Any]:
"""안쪽 창구 응답 → dict · 오류 응답이면 StepFailed 로(까닭 그대로)."""
if isinstance(response, JSONResponse):
content = json.loads(bytes(response.body).decode("utf-8") or "{}")
if response.status_code >= 400 or content.get("status") == "error":
raise StepFailed("", str(content.get("message") or f"HTTP {response.status_code}"))
return content
if hasattr(response, "model_dump"):
return response.model_dump(mode="json")
return dict(response or {})
def _patched_points(stored: dict[str, Any], patches: dict[str, dict[str, Any]]) -> list | None:
"""저장된 관 목록에 관 옵션 조각 — 고친 것이 없으면 None(브라우저 `flushCulvertOptions` 짝)."""
points, touched = [], False
for point in stored.get("pipe_points") or []:
item = {key: value for key, value in point.items() if key != "lonlat"}
patch = patches.get(f"{float(point['chainage_m']):.2f}")
if patch:
touched = True
item["options"] = {**(item.get("options") or {}), **patch}
points.append(item)
return points if touched else None
async def _run_steps(
project_id: UUID, request: DesignSaveRequest, session: dict[str, Any] | None
) -> dict[str, Any]:
"""차례대로 안쪽 창구 · 차례 이름째 실패를 던짐 → 차례별 응답(알림 · 화면 맞춤용)."""
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points, put_pipe_points
from B05_Profile.B05_Profile_Router import save_profile_alignment
from B05_Profile.B05_Profile_Router_Lifecycle import confirm_latest_route, save_page_settings
from B05_Profile.B05_Profile_Structures_Router import write_structures
from B06_Section.B06_Section_Router_Confirm import confirm_sections, save_sections
from B06_Section.B06_Section_Router_Stations import put_plus_stations
out: dict[str, Any] = {}
async def step(name: str, call: Any) -> dict[str, Any]:
try:
out[name] = _body(await call)
except StepFailed as failed:
raise StepFailed(name, str(failed)) from None
except Exception as error:
logger.exception("한 본문 저장 %s 차례 실패: project_id=%s", name, project_id)
raise StepFailed(name, str(error) or type(error).__name__) from None
# 시험 갈래 — 화면에서 실패 · 되돌림을 돌려 볼 때만(서버를 이 환경 변수로 켬 · 평소 없음)
if os.environ.get("AISLO_DESIGN_SAVE_FAIL_AFTER") == name:
raise StepFailed(name, "시험 갈래 — 이 차례 뒤 일부러 실패")
return out[name]
if request.settings is not None:
await step("settings", save_page_settings(project_id, request.settings))
if request.pipes is not None or request.confirm:
pipes = await step(
"pipes", put_pipe_points(project_id, request.pipes or {}, session, request.confirm)
)
if pipes.get("stations_failed"):
raise StepFailed("pipes", "관 자리 측점을 못 맞췄습니다")
if request.structures is not None:
await step("structures", write_structures(project_id, request.structures, session))
if request.plus_stations is not None:
await step("plusStations", put_plus_stations(project_id, request.plus_stations))
if request.alignment is not None:
await step("alignment", save_profile_alignment(project_id, request.alignment))
if request.culvert_options:
stored = await step("culvertOptions", get_pipe_points(project_id))
points = _patched_points(stored, request.culvert_options)
if points is not None:
await step(
"culvertOptions", put_pipe_points(project_id, {"points": points}, session, False)
)
if request.uphill is not None:
parts = await step("uphill", confirm_latest_route(project_id, request.uphill, False))
if parts.get("failed_parts"):
raise StepFailed("uphill", " · ".join(parts["failed_parts"]) + " 실패")
if request.cross is not None:
save = confirm_sections if request.confirm else save_sections
cross = await step("cross", save(project_id, request.route_id, request.cross))
if cross.get("recomputed") is False:
raise StepFailed("cross", "서버 재계산 실패")
if cross.get("stations_synced") is False:
raise StepFailed("cross", "구조물 측점 맞춤 실패")
return out
@router.post("/{project_id}/design/save")
async def save_design(
project_id: UUID,
request: DesignSaveRequest,
session: dict[str, Any] | None = Depends(get_optional_session),
) -> JSONResponse:
"""[저장] · [확정] 한 본문 — 전부 저장하거나 아무것도 안 바꿈."""
lock = guard.project_lock(str(project_id))
waited = lock.locked()
async with lock:
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
rows = await guard.snapshot_rows(connection, str(project_id))
root = Path(resolve_stored_project_path(stored_path))
pipes = request.pipes is not None or bool(request.culvert_options) or request.confirm
files = guard.snapshot_storage(
root, guard.STORAGE_SCOPE + (guard.PIPE_SCOPE if pipes else ())
)
try:
steps = await _run_steps(project_id, request, session)
except StepFailed as failed:
restored = await _restore(project_id, rows, files)
return JSONResponse(
status_code=409 if restored else 500,
content={
"status": "error",
"step": failed.step,
"message": str(failed)
if restored
else f"{failed} — 저장 실패 · 복구 확인 필요",
"restored": restored,
"waited": waited,
},
)
finally:
files.discard()
return JSONResponse(content={"status": "success", "waited": waited, "steps": steps})
async def _restore(project_id: UUID, rows: dict[str, list], files: guard.StorageSnapshot) -> bool:
"""떠 둔 대로 — 성공 = 참 · 하나라도 못 되돌리면 로그 남기고 거짓(조용히 넘기지 않음)."""
ok = True
try:
files.restore()
except Exception:
logger.exception("한 본문 저장 되돌림 실패(storage): project_id=%s", project_id)
ok = False
try:
async with get_db_pool().acquire() as connection:
await guard.restore_rows(connection, str(project_id), rows)
except Exception:
logger.exception("한 본문 저장 되돌림 실패(DB): project_id=%s", project_id)
ok = False
return ok
+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 () => {
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
+199
View File
@@ -0,0 +1,199 @@
"""[저장] · [확정] 전부 아니면 없음(PLAN 472-6 · 브레인 A) — 떠 두고 실패면 되돌림.
차례마다 안쪽 창구가 저마다 커넥션 · 파일을 쓰므로 DB 트랜잭션 하나로 못 꿴다. 대신 시작 전에
- DB 행 — `projects` 한 줄 · `project_workflow_stages` · `routes` 와 자식 넷(`route_points` ·
`route_statistics` · `longitudinal_sections` · `cross_sections`)
- storage — 저장 차례가 쓰는 폴더(`STORAGE_SCOPE` · 관 차례가 있으면 배수 폴더도)
를 떠 두고, 하나라도 실패하면 둘 다 되돌린다. 되돌림 쓰기는 바뀐 것만(시놀로지 동기 — 안 바뀐
파일 · 표는 안 씀). 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`project_lock`).
"""
from __future__ import annotations
import asyncio
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import aiomysql
#: 저장 차례가 쓰는 storage 자리(프로젝트 뿌리 기준) — 추천 노선 미리 셈(`route_recommend`)은 뒤에서
#: 따로 도는 곳이라 안 넣음.
STORAGE_SCOPE = (
"B05_Profile/route",
"B05_Profile/corridor",
"B06_Section",
"B07_DesignDetail",
"_derived",
"templates",
"workflow.json",
"project_manifest.json",
)
#: 관 · 관 옵션 차례 · [확정](배수유역 다시 셈)이 쓰는 자리.
PIPE_SCOPE = ("B04_PreProcess/drainage",)
#: 노선 아래 표(노선 행이 지워지면 함께 지워짐 · CASCADE).
ROUTE_CHILDREN = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections")
_LOCKS: dict[str, asyncio.Lock] = {}
def project_lock(project_id: str) -> asyncio.Lock:
"""같은 프로젝트 저장 한 번에 하나 — 뒤 요청은 앞 저장이 끝날 때까지 기다림.
ponytail: 프로세스 메모리 잠금 · 서버 여러 대면 DB 잠금으로."""
return _LOCKS.setdefault(str(project_id), asyncio.Lock())
# ── storage ────────────────────────────────────────────────────────────────
def _stamp(path: Path) -> tuple[int, int]:
stat = path.stat()
return stat.st_size, stat.st_mtime_ns
def _files(root: Path, scope: tuple[str, ...]) -> dict[str, Path]:
out: dict[str, Path] = {}
for relative in scope:
target = root / relative
if target.is_file():
out[relative] = target
elif target.is_dir():
for path in target.rglob("*"):
if path.is_file():
out[path.relative_to(root).as_posix()] = path
return out
@dataclass
class StorageSnapshot:
"""떠 둔 파일 — 사본 폴더(임시) · 파일마다 (크기, 바뀐 때)."""
root: Path
scope: tuple[str, ...]
copy_dir: Path
stamps: dict[str, tuple[int, int]] = field(default_factory=dict)
def restore(self) -> int:
"""바뀐 · 새로 생긴 · 지워진 파일만 되돌림 → 되쓴 수(지움 포함)."""
written = 0
now = _files(self.root, self.scope)
for relative, path in now.items():
if relative not in self.stamps:
path.unlink()
written += 1
elif _stamp(path) != self.stamps[relative]:
shutil.copy2(self.copy_dir / relative, path)
written += 1
for relative in self.stamps.keys() - now.keys():
target = self.root / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(self.copy_dir / relative, target)
written += 1
return written
def changed(self) -> list[str]:
"""떠 둔 뒤 바뀐 자리(시험 · 로그)."""
now = _files(self.root, self.scope)
moved = [r for r, p in now.items() if self.stamps.get(r) != _stamp(p)]
return sorted(moved + list(self.stamps.keys() - now.keys()))
def discard(self) -> None:
shutil.rmtree(self.copy_dir, ignore_errors=True)
def snapshot_storage(root: Path, scope: tuple[str, ...]) -> StorageSnapshot:
copy_dir = Path(tempfile.mkdtemp(prefix="aislo_save_"))
snap = StorageSnapshot(Path(root), scope, copy_dir)
for relative, path in _files(snap.root, scope).items():
target = copy_dir / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
snap.stamps[relative] = _stamp(path)
return snap
# ── DB ─────────────────────────────────────────────────────────────────────
async def _select(cursor: Any, sql: str, args: tuple | list) -> list[dict[str, Any]]:
await cursor.execute(sql, args)
return [dict(row) for row in await cursor.fetchall()]
async def snapshot_rows(connection: aiomysql.Connection, project_id: str) -> dict[str, list]:
"""프로젝트 한 줄 · 단계 · 노선과 자식 넷의 행(값 그대로)."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
rows = {
"projects": await _select(cursor, "SELECT * FROM projects WHERE id=%s", (project_id,)),
"project_workflow_stages": await _select(
cursor,
"SELECT * FROM project_workflow_stages WHERE project_id=%s ORDER BY id",
(project_id,),
),
"routes": await _select(
cursor, "SELECT * FROM routes WHERE project_id=%s ORDER BY id", (project_id,)
),
}
ids = [row["id"] for row in rows["routes"]] or [-1]
marks = ",".join(["%s"] * len(ids))
for table in ROUTE_CHILDREN:
rows[table] = await _select(
cursor, f"SELECT * FROM {table} WHERE route_id IN ({marks}) ORDER BY id", ids
)
return rows
async def _insert(cursor: Any, table: str, rows: list[dict[str, Any]]) -> None:
if not rows:
return
columns = list(rows[0])
names = ",".join(f"`{name}`" for name in columns)
marks = ",".join(["%s"] * len(columns))
await cursor.executemany(
f"INSERT INTO {table} ({names}) VALUES ({marks})",
[[row[name] for name in columns] for row in rows],
)
async def restore_rows(
connection: aiomysql.Connection, project_id: str, saved: dict[str, list]
) -> list[str]:
"""바뀐 표만 떠 둔 행으로 — 한 트랜잭션 → 되돌린 표 이름."""
now = await snapshot_rows(connection, project_id)
tree = ("routes", *ROUTE_CHILDREN)
redo_tree = any(now[table] != saved[table] for table in tree)
redo_stages = now["project_workflow_stages"] != saved["project_workflow_stages"]
redo_project = now["projects"] != saved["projects"] and bool(saved["projects"])
if not (redo_tree or redo_stages or redo_project):
return []
await connection.begin()
try:
async with connection.cursor() as cursor:
if redo_tree:
await cursor.execute("DELETE FROM routes WHERE project_id=%s", (project_id,))
for table in tree:
await _insert(cursor, table, saved[table])
if redo_stages:
await cursor.execute(
"DELETE FROM project_workflow_stages WHERE project_id=%s", (project_id,)
)
await _insert(cursor, "project_workflow_stages", saved["project_workflow_stages"])
if redo_project:
row = saved["projects"][0]
columns = [name for name in row if name != "id"]
sets = ",".join(f"`{name}`=%s" for name in columns)
await cursor.execute(
f"UPDATE projects SET {sets} WHERE id=%s",
[row[name] for name in columns] + [project_id],
)
await connection.commit()
except Exception:
await connection.rollback()
raise
return [
*(tree if redo_tree else ()),
*(("project_workflow_stages",) if redo_stages else ()),
*(("projects",) if redo_project else ()),
]
@@ -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
@@ -50,7 +50,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` · 나머지 추정 |
+2
View File
@@ -54,6 +54,7 @@ from B05_Profile.B05_Profile_Router_Terrain import router as b05_route_terrain_r
from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router
from B06_Section.B06_Section_Router import router as b06_section_router
from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router
from B06_Section.B06_Section_Router_DesignSave import router as b06_design_save_router
from B06_Section.B06_Section_Router_Confirm import (
router as b06_section_confirm_router,
)
@@ -646,6 +647,7 @@ app.include_router(b05_route_terrain_router, dependencies=protected_with_company
app.include_router(b05_structures_router, dependencies=protected_with_company)
app.include_router(b06_section_router, dependencies=protected_with_company)
app.include_router(b06_section_stations_router, dependencies=protected_with_company)
app.include_router(b06_design_save_router, dependencies=protected_with_company)
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
app.include_router(b06_section_cross_table_router, dependencies=protected_with_company)
+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 () => {
+6 -1
View File
@@ -105,6 +105,7 @@ async function run() {
const feeder = loader({
"../../B05_Profile/B05_Profile_UI_Profile_History": {
onPageHistory: (hooks) => ((restore = hooks.restore), () => undefined),
pageReplaying: () => false, // 묶음 먹이기가 되돌리는 동안 구독을 거름(472-5)
},
"./design_save": { savingDesign: () => false },
})(path.join(DESIGN, "design_pipeline_facilities.ts"));
@@ -116,7 +117,11 @@ async function run() {
saved: (key) => (key === "plusStations" ? [12] : []),
input: (key) => inputNow[key] ?? null,
routed: () => false,
feed: (source, value) => fed.push([source, value]),
// 묶음처럼 받은 값이 입력이 됨 — 같은 값 다시 알림은 안 넣음(472-5)
feed: (source, value) => {
fed.push([source, value]);
if (source === "plus") inputNow.plusStations = value;
},
resync: async () => undefined,
});
fakeStore.set("plusStations", [12, 30]);
+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 },
+32 -1
View File
@@ -43,19 +43,49 @@ const pipes2 = [
];
store.set("structures", A);
store.set("pipes", pipes1);
m.writePlusDraft(P, [60]);
const history = m.createProfileHistory(() => undefined, 100);
store.set("structures", AB);
store.set("pipes", pipes2);
m.writePlusDraft(P, [60, 80]);
history.record();
const heard = { structures: [], pipes: [] };
const heard = { structures: [], pipes: [], plusStations: [] };
store.subscribe("structures", (v) => heard.structures.push(v));
store.subscribe("pipes", (v) => heard.pipes.push(v));
store.subscribe("plusStations", (v) => heard.plusStations.push(v));
// 계산 묶음 먹이기 — 되돌리기 한 번에 출처마다 한 번만(되살림 훅 한 길 · 472-5 겹침 정리)
const fed = [];
const input = { pipes: pipes2, structures: AB, plusStations: [60, 80] };
const keyOf = { pipe: "pipes", structure: "structures", plus: "plusStations" };
m.feedFacilities({
projectId: P,
store,
ready: () => true,
gone: () => false,
saved: () => null,
input: (key) => input[key],
routed: () => false,
feed: (source, value) => {
fed.push(source);
input[keyOf[source]] = value;
},
resync: async () => undefined,
});
const tick = () => new Promise((done) => setTimeout(done, 5));
history.undo();
await tick();
const afterUndo = {
structures: heard.structures.at(-1) ?? null,
pipes: heard.pipes.at(-1) ?? null,
};
const counts = {
structures: heard.structures.length,
pipes: heard.pipes.length,
plusStations: heard.plusStations.length,
fed: [...fed].sort(),
};
history.redo();
await tick();
const afterRedo = {
structures: heard.structures.at(-1) ?? null,
pipes: heard.pipes.at(-1) ?? null,
@@ -85,6 +115,7 @@ console.log(
JSON.stringify({
afterUndo,
afterRedo,
counts,
echo,
adopted,
picked,
@@ -4,3 +4,5 @@ export { pinDraftProject, useDesignStore } from "../../A00_Common/design/design_
export { adoptEditorPipes } from "../../B05_Profile/B05_Profile_UI_Drainage_Parts";
export { createPipeEditor } from "../../B05_Profile/B05_Profile_UI_Drainage_Pipes";
export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility";
export { feedFacilities } from "../../A00_Common/design/design_pipeline_facilities";
export { writePlusDraft } from "../../B05_Profile/B05_Profile_Api_PlusStations";
+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):
+17 -2
View File
@@ -1,8 +1,9 @@
"""PLAN 472-5 5-A (요구 273) — 관 · 구조물 값 주인 하나(설계 가게) · 사본 갈림 막음.
못박는 것
- 되돌리기 · 다시하기(세션 직접 되씀)가 가게 듣는 쪽을 되돌린 값으로 부름 — 옛 코드는 안 불러
종단 목록 · 횡단 판 · 배수 편집기가 옛 값을 쥐고 다음 조작이 되돌림을 덮었음(옛 코드 실패).
- 되돌리기 · 다시하기(세션 직접 되씀)가 가게 듣는 쪽을 되돌린 값으로 한 번씩 부름(5-C 훅 · 묶음과
안 겹침) — 옛 코드는 안 불러 종단 목록 · 횡단 판 · 배수 편집기가 옛 값을 쥐고 다음 조작이
되돌림을 덮었음(옛 코드 실패).
- 배수 편집기 받기 — 제 쓰기 메아리는 안 받음 · 남이 고친 옵션 · 차례도 받음 · 고른 관(id) 유지.
- 배선 — 종단 보기 = 관(숨어도) · 구조물(보일 때) 구독 · 횡단 보기 = 남 고침(옵션만도) 다시 실음 ·
횡단 판은 쓰기 전에 제 값을 적음(메아리 가름).
@@ -75,6 +76,16 @@ def test_되돌리기가_가게_듣는_쪽을_부름(out) -> None:
assert out["afterRedo"]["pipes"][0]["options"] == {"diameter_mm": 1000}
def test_되돌리기_알림은_한_번씩(out) -> None:
"""겹침 정리 — 관 · 구조물 = 가게 알림 한 번 · 추가 측점 = 자기 훅 · 묶음 = 출처마다 한 번."""
assert out["counts"] == {
"structures": 1,
"pipes": 1,
"plusStations": 1,
"fed": ["pipe", "plus", "structure"],
}
def test_배수_편집기_남_고침만_받음(out) -> None:
assert out["echo"] is False
assert out["adopted"] is True
@@ -105,5 +116,9 @@ def test_배선() -> None:
assert panel.index("seen.structures = JSON.stringify(structures);") < panel.index(
"writePendingStructures(deps.projectId, structures);"
)
# 열린 관 폼 — 밖에서 바뀐 값(되돌리기 · 다른 보기)으로 다시 엶(옛 칸 값 되쓰던 흠)
form = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts")
assert "if (at === null || applyingLive || tempPipeChainage !== null) return;" in form
assert "JSON.stringify(now) !== JSON.stringify(before.find(near))) loadPipeForm(now);" in form
history = _read("B05_Profile", "B05_Profile_UI_Profile_History.ts")
assert "announceDesignStore(projectId);" in history
+254
View File
@@ -0,0 +1,254 @@
"""PLAN 472-6 6-A — [저장] · [확정] 한 본문 창구 · 전부 아니면 없음.
① storage 떠 두기 · 되돌림 — 바뀐 · 새로 생긴 · 지운 파일만 되씀(안 바뀐 파일 그대로).
② 실제 창구(검증_sub8 · DB · storage) — 중간 차례 실패 → DB 7 표 행 · storage 해시 시작 그대로 ·
차례 이름 · 까닭 · restored. 같은 본문 정상 = 옛 차례(안쪽 창구 차례로 부름)와 행 · 파일 같음.
검증 프로젝트가 없는 자리(다른 PC · DB 없음)는 ② 건너뜀.
./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_design_save.py -q
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import sys
from pathlib import Path
from uuid import UUID
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
sys.path.insert(0, str(ROOT))
from common_util import common_util_save_guard as guard # noqa: E402
PROJECT = "9872e9a7-e483-427b-a6b2-72cdbc063bc3" # 검증_sub8(OWNERS.md)
VOLATILE = ("created_at", "updated_at", "confirmed_at", "started_at", "completed_at")
def test_storage_되돌림은_바뀐_것만(tmp_path) -> None:
(tmp_path / "B06_Section" / "a").mkdir(parents=True)
keep = tmp_path / "B06_Section" / "a" / "keep.json"
edit = tmp_path / "B06_Section" / "edit.json"
gone = tmp_path / "workflow.json"
for path, text in ((keep, "k"), (edit, "e"), (gone, "w")):
path.write_text(text, encoding="utf-8")
outside = tmp_path / "B03_FileInput.txt"
outside.write_text("o", encoding="utf-8")
snap = guard.snapshot_storage(tmp_path, ("B06_Section", "workflow.json"))
kept_stamp = keep.stat().st_mtime_ns
edit.write_text("changed", encoding="utf-8")
gone.unlink()
(tmp_path / "B06_Section" / "new.json").write_text("n", encoding="utf-8")
outside.write_text("o2", encoding="utf-8") # 범위 밖은 안 건드림
assert snap.changed() == ["B06_Section/edit.json", "B06_Section/new.json", "workflow.json"]
assert snap.restore() == 3
assert edit.read_text(encoding="utf-8") == "e" and gone.read_text(encoding="utf-8") == "w"
assert not (tmp_path / "B06_Section" / "new.json").exists()
assert keep.stat().st_mtime_ns == kept_stamp and outside.read_text(encoding="utf-8") == "o2"
snap.discard()
assert not snap.copy_dir.exists()
# ── 실제 창구 ──────────────────────────────────────────────────────────────
def _db_ready() -> Path | None:
try:
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import close_db_pool, init_db_pool
async def find():
pool = await init_db_pool()
try:
async with pool.acquire() as connection:
return await get_project_storage_relative_path(connection, UUID(PROJECT))
finally:
await close_db_pool()
root = Path(resolve_stored_project_path(asyncio.run(find())))
return root if (root / "B06_Section" / "longitudinal").is_dir() else None
except Exception:
return None
ROOT_DIR = _db_ready()
live = pytest.mark.skipif(ROOT_DIR is None, reason="검증_sub8 · DB 없음")
def _state(root: Path) -> tuple[dict, dict]:
"""(행 — 시각 칸 뺌, 파일 내용 해시) — 저장 범위 전부."""
from config.config_db import close_db_pool, init_db_pool
async def rows():
pool = await init_db_pool()
try:
async with pool.acquire() as connection:
return await guard.snapshot_rows(connection, PROJECT)
finally:
await close_db_pool()
raw = asyncio.run(rows())
clean = {
table: [{k: str(v) for k, v in row.items() if k not in VOLATILE} for row in values]
for table, values in raw.items()
}
scope = guard.STORAGE_SCOPE + guard.PIPE_SCOPE
files = {
relative: hashlib.sha1(path.read_bytes()).hexdigest()
for relative, path in guard._files(root, scope).items()
if not relative.startswith("_derived/")
}
return clean, files
def _request(**extra):
from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest
body = {
"route_id": 380,
"settings": {"design_speed_kph": 20, "terrain_type": "special", "max_grade_pct": 13.0},
"plus_stations": {"chainages": [500.0]},
"cross": {},
**extra,
}
return DesignSaveRequest.model_validate(body)
def _save(request, monkeypatch=None):
from B06_Section.B06_Section_Router_DesignSave import save_design
from config.config_db import close_db_pool, init_db_pool
async def run():
await init_db_pool()
try:
return await save_design(UUID(PROJECT), request, None)
finally:
await close_db_pool()
response = asyncio.run(run())
return response.status_code, json.loads(bytes(response.body).decode("utf-8"))
@live
def test_중간_차례_실패면_DB_storage_그대로(monkeypatch) -> None:
from B06_Section import B06_Section_Router_Stations as stations
before = _state(ROOT_DIR)
async def broken(project_id, request):
(ROOT_DIR / "B06_Section" / "half_written.json").write_text("{}", encoding="utf-8")
raise RuntimeError("일부러 실패")
monkeypatch.setattr(stations, "put_plus_stations", broken)
status, body = _save(_request())
assert status == 409 and body["step"] == "plusStations" and body["restored"] is True
assert "일부러 실패" in body["message"]
after = _state(ROOT_DIR)
assert after[0] == before[0] # 앞 차례(페이지 설정)가 쓴 단계 params 도 되돌림
assert after[1] == before[1]
@live
def test_정상_한_본문은_옛_차례와_같음() -> None:
from B05_Profile.B05_Profile_Router_Lifecycle import save_page_settings
from B06_Section.B06_Section_Router_Confirm import save_sections
from B06_Section.B06_Section_Router_Stations import put_plus_stations
from config.config_db import close_db_pool, get_db_pool, init_db_pool
start = _state(ROOT_DIR)
request = _request()
async def old_way():
await init_db_pool()
try:
async with get_db_pool().acquire() as connection:
rows = await guard.snapshot_rows(connection, PROJECT)
files = guard.snapshot_storage(ROOT_DIR, guard.STORAGE_SCOPE)
await save_page_settings(UUID(PROJECT), request.settings)
await put_plus_stations(UUID(PROJECT), request.plus_stations)
await save_sections(UUID(PROJECT), 380, request.cross)
return rows, files
finally:
await close_db_pool()
rows, files = asyncio.run(old_way())
old = _state(ROOT_DIR)
async def undo():
await init_db_pool()
try:
files.restore()
async with get_db_pool().acquire() as connection:
await guard.restore_rows(connection, PROJECT, rows)
finally:
await close_db_pool()
asyncio.run(undo())
assert _state(ROOT_DIR) == start
try:
status, body = _save(request)
assert status == 200 and body["status"] == "success", body
assert set(body["steps"]) == {"settings", "plusStations", "cross"}
new = _state(ROOT_DIR)
assert new[0] == old[0]
assert new[1] == old[1]
finally:
asyncio.run(undo())
files.discard()
assert _state(ROOT_DIR) == start
@live
def test_되돌림마저_실패면_복구_확인_필요(monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle
async def broken(project_id, request):
raise RuntimeError("설정 실패")
def no_restore(self):
raise OSError("디스크 잠김")
monkeypatch.setattr(lifecycle, "save_page_settings", broken)
monkeypatch.setattr(guard.StorageSnapshot, "restore", no_restore)
status, body = _save(_request())
assert status == 500 and body["restored"] is False and body["step"] == "settings"
assert "복구 확인 필요" in body["message"]
@live
def test_같은_프로젝트_저장이_겹치면_뒤_요청이_기다림() -> None:
from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest, save_design
from config.config_db import close_db_pool, init_db_pool
async def run():
await init_db_pool()
try:
lock = guard.project_lock(PROJECT)
await lock.acquire()
task = asyncio.create_task(
save_design(UUID(PROJECT), DesignSaveRequest(route_id=380), None)
)
await asyncio.sleep(0.3)
assert not task.done() # 앞 저장(잠금)이 끝날 때까지 기다림
lock.release()
return await task
finally:
await close_db_pool()
response = asyncio.run(run())
body = json.loads(bytes(response.body).decode("utf-8"))
assert body["status"] == "success" and body["waited"] is True and body["steps"] == {}
@live
def test_시험_갈래_환경_변수면_그_차례_뒤_실패_되돌림(monkeypatch) -> None:
before = _state(ROOT_DIR)
monkeypatch.setenv("AISLO_DESIGN_SAVE_FAIL_AFTER", "settings")
status, body = _save(_request())
assert status == 409 and body["step"] == "settings" and body["restored"] is True
assert _state(ROOT_DIR) == before
+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"] == []