Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 · 저장 한 줄기가 그 차례에서 멈춤 ·
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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 () => {
|
||||
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` · 나머지 추정 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 로 바로 — 노선 캐시 없음 = 서버에서 한 번 읽고 같은 본문
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)"), (
|
||||
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user