Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

충돌 풂: design_pipeline_page holding(472-5 덧) · commit(472-6 6-B) 둘 다 살림 · 위키 [저장] 줄 = 한 본문 판

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
This commit is contained in:
2026-10-19 12:41:29 +09:00
co-authored by Claude Opus 5.5
82 changed files with 2320 additions and 1272 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ export const STATE_REGISTRY = {
uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` },
/** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림.
* 예전에는 패널 메모리에만 있어 B06 으로 넘어가면 편집이 사라졌다(대응표 조사).
* 정본은 `pipe_points.json` 이고, 이 초안은 [저장]·[확정]에서 `flushPendingPipes` 가
* 정본은 `pipe_points.json` 이고, 이 초안은 [저장]·[확정] 한 본문(`pipesPart`)이
* 내보낸 뒤 비운다. */
pipes: { bucket: "draft", scope: "project", legacy: (p) => `b05:pipes:${p}` },
/** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */
+1 -1
View File
@@ -19,7 +19,7 @@ B05 · B06 한 페이지 뼈대 — 주소 둘(`#/b05-profile` · `#/b06-section
서버 Node 묶음:
- 입구 `logic/design_calc_node.ts`(`build:design-calc` · 계산 등록부 `logic/design_calc_registry.ts` 의 `node:` 줄 · 시험 `test_472_3_calc_registry.py`) · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state`
- 입구 `logic/design_calc_node.ts`(`build:design-calc` · 계산 등록부 `logic/design_calc_registry.ts` · `_facility.ts` 의 `node:` 줄 · 시험 `test_472_3_calc_registry.py`) · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state`
- 끌려가는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 · 고치면 해당 build 다시 지어 시험
남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도):
@@ -130,6 +130,7 @@ interface PagePipeline {
detail(): SectionDetailResponse | null;
/** 노선 초안 버림 동안(그리기 멈춤 ~ 풂) — 저장 노선 낡음 다시 셈을 안 띄움(472-5 덧). */
holding(): boolean;
commit(): void;
dispose(): void;
}
@@ -184,6 +185,9 @@ export const pipelineDetail = (projectId: string): SectionDetailResponse | null
export const pagePipeline = (projectId: string): DesignPipeline | null =>
pages.get(projectId)?.pipe ?? null;
/** 노선 초안을 저장함(472-6 6-B) — 초안 전 관 · 구조물을 버림(노선 초안이 비어도 「버림」 되돌림 안 함). */
export const commitRouteFacilities = (projectId: string): void => pages.get(projectId)?.commit();
export function disposePagePipeline(projectId: string): void {
pages.get(projectId)?.dispose();
pages.delete(projectId);
@@ -676,6 +680,7 @@ function createPagePipeline(
ownsCross: Boolean(options.savedCross),
detail: () => draftDetailOf(projectId),
holding: () => holding,
commit: () => void (beforeDraft = null),
dispose() {
disposed = true;
setDraftSectionDetail(projectId, null);
+88 -276
View File
@@ -1,96 +1,53 @@
/* =============================================================================
* A00_Common/design/design_save.ts
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
* B05 · B06 **저장 한 줄기**(계획서 24-1 · 472-6) — [저장] · [확정]이 어느 보기에서 눌려도 같은 한 본문.
*
* 노선 → 페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
* (노선) → 페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
*
* 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan`
* (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고
* 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 →
* 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움.
* 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄).
* 노선 저장 뒤 다른 차례가 실패하면 초안을 새 노선 칸으로 옮기고 페이지를 새 노선으로(433-12).
* 어느 차례든 실패하면 그 차례에서 멈추고 차례 이름째 알림(429 ⑨ · 뒤 차례는 안 보냄).
* 노선을 저장했으면 횡단은 고침이 없어도 한 번 나감 — 뒤에 나간 계획고 초안을 서버가 횡단에 다시 셈(429 ④).
* 차례 조각(세션 초안 → 본문 + 저장 뒤 정리)을 모아 서버 한 창구(`POST /design/save`)에 한 번 — 서버가 차례대로
* 안쪽 창구를 부르고 전부 저장하거나 아무것도 안 바꿈(472-6 6-A). 서버가 다 저장한 뒤에만 조각 정리 · 실패면
* 초안 그대로(다음 [저장]에서 다시) · 차례 이름째 알림.
*
* 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일**
* (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라,
* 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다.
* 노선(346-3 · 6-B) — 노선 초안이 마지막 저장과 다르면 같은 본문에 `route`(계약 `RouteDraft`) · `carry`(옛 노선
* 세션 횡단 고침 원본)를 실음. 서버가 맨 앞에서 노선 저장(관 · 구조물 · 추가 측점 좌표 따라감 · 종횡단 · 계획고
* 이월) → 관 · 구조물 초안 다시 얹기 → 새 노선으로 위 차례 → 횡단 = 세션 고침을 측점 열쇠로 넘긴 patch(같은 TS ·
* Node). 이 때 조각은 기본 조각만(화면 몫 일 = 옛 노선 판이라 건너뜀) · 계획고 초안 = 노선 초안 칸 ?? 옛 노선 칸 ·
* 손 상단측은 안 보냄(요구 274) · [확정]도 [저장]으로(새 노선을 본 뒤 다시 [확정]) · 다 저장하면 페이지 다시 세움.
*
* ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은
* 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12).
* 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12) — 차례 = 서버 한 곳.
* 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음).
* ========================================================================== */
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import {
flushPendingPipes,
pipesPart,
readPendingPipes,
writePendingPipes,
} from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { rebasePipeDraft, rebaseStructureDraft } from "../../common_util/common_util_route_follow";
import {
fetchStructures,
flushPendingStructures,
readPendingStructures,
structuresPart,
writePendingStructures,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import {
flushPlusStations,
outsideNotice,
plusStationsPart,
} from "../../B05_Profile/B05_Profile_Api_PlusStations";
import {
flushRouteParts,
routePartsBody,
saveProfileAlignment,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import { pipesPart } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { structuresPart } from "../../B05_Profile/B05_Profile_Api_Structures";
import { outsideNotice, plusStationsPart } from "../../B05_Profile/B05_Profile_Api_PlusStations";
import { routePartsBody } from "../../B05_Profile/B05_Profile_Api_Fetch";
import type { RouteReplanResponse } from "../../B05_Profile/B05_Profile_Api_Replan";
import {
markAlignmentSaved,
pendingAlignment,
writeAlignmentDraft,
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import {
culvertOptionsPart,
flushCulvertOptions,
} from "../../B06_Section/B06_Section_Api_Culvert_Options";
import {
flushPageSettings,
pageSettingsPart,
} from "../../B05_Profile/B05_Profile_Api_PageSettings";
import { culvertOptionsPart } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import { pageSettingsPart } from "../../B05_Profile/B05_Profile_Api_PageSettings";
import {
DesignSaveError,
postDesignSave,
SAVE_PART_KEYS,
type SavePart,
type SavePartKey,
type SaveStepKey,
} from "./design_save_api";
import { clearState, stateKey } from "../b_page_state";
import {
carryAfterRouteSave,
pendingRouteSave,
rebuildAfterRouteSave,
routeCarryPart,
routeSaved,
routeSaveSummary,
saveRouteDraft,
} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply";
/** 저장 차례 — 이 순서 그대로 돈다(`route` 는 노선 초안이 있을 때만). */
export const DESIGN_SAVE_ORDER = [
"route",
"settings",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
"cross",
] as const;
export type DesignSaveStep = (typeof DESIGN_SAVE_ORDER)[number];
import type { RouteSaveBody } from "../../B05_Profile/B05_Profile_Api_Replan";
export interface DesignSaveContext {
projectId: string;
@@ -122,9 +79,8 @@ export function savingDesign(projectId: string | null): boolean {
return projectId !== null && saving.has(projectId);
}
/** 차례 이름 — 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게 ·
* 초안은 세션에 남아 다음 [저장]에서 다시 나감). */
const STEP_LABEL: Record<DesignSaveStep, string> = {
/** 차례 이름 — 실패하면 그 차례 이름째 알림(429 ⑨ · 초안은 세션에 남아 다음 [저장]에서 다시 나감). */
const STEP_LABEL: Record<SaveStepKey, string> = {
route: "노선",
settings: "페이지 설정",
pipes: "배수관",
@@ -136,11 +92,6 @@ const STEP_LABEL: Record<DesignSaveStep, string> = {
cross: "횡단",
};
function stepFailed(step: DesignSaveStep, error: unknown): Error {
const detail = error instanceof Error ? ` ${error.message}` : "";
return new Error(`${STEP_LABEL[step]} 차례에서 멈췄습니다(뒤 차례 안 보냄).${detail}`);
}
/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */
export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void {
const dropped = saved.dropped_edit_chainages ?? [];
@@ -159,186 +110,19 @@ export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] })
/** 노선 초안 동안의 계획고 초안 칸 — `design_pipeline_page` 의 `DRAFT_ROUTE_ID` 와 같은 값(sub7). */
const DRAFT_ROUTE_ID = 0;
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. 노선이 바뀌었으면
* 노선 초안 칸(`DRAFT_ROUTE_ID`) · 없으면 옛 노선 칸(`from`)의 초안을 새 노선(`routeId`)에 · 두 칸 다
* 비움 — 측점이 없는 자리는 서버가 버리고 알림(213-2). */
async function flushAlignmentDraft(
projectId: string,
routeId: number,
from: number = routeId,
): Promise<void> {
const moved = from !== routeId;
const draft = (moved ? pendingAlignment(DRAFT_ROUTE_ID) : null) ?? pendingAlignment(from);
if (!draft) return;
noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
markAlignmentSaved(from);
if (moved) markAlignmentSaved(DRAFT_ROUTE_ID);
}
/** 노선은 저장했는데 뒤 차례가 실패 — 계획고 초안(노선 초안 칸 · 옛 노선 칸)을 새 노선 칸에 · 횡단 초안을 새
* 측점 열쇠로(저장 없이 · 이미 넘겼으면 안 함) · 페이지를 새 노선으로 다시 세움(433-12). 다음 [저장]이 그대로 보냄. */
async function keepDraftsOnNewRoute(
projectId: string,
fromRouteId: number,
routeId: number,
carried: boolean,
): Promise<void> {
const draft = pendingAlignment(DRAFT_ROUTE_ID) ?? pendingAlignment(fromRouteId);
if (draft) {
writeAlignmentDraft(routeId, draft);
markAlignmentSaved(DRAFT_ROUTE_ID);
markAlignmentSaved(fromRouteId);
}
if (!carried) await carryAfterRouteSave(projectId, fromRouteId, routeId, { save: false });
await rebuildAfterRouteSave(projectId);
}
/** 차례마다 기본 창구. */
function defaultStep(
step: Exclude<DesignSaveStep, "cross" | "route">,
projectId: string,
routeId: number,
fromRouteId: number = routeId,
confirm = false,
): () => Promise<void> {
switch (step) {
case "settings": // 노선 뒤 — 노선 저장이 새로 만든 종단에도 경고 기준이 얹힘(448)
return () => flushPageSettings(projectId);
case "pipes":
return () => flushPendingPipes(projectId, confirm);
case "structures":
return () => flushPendingStructures(projectId);
case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2)
return () => flushPlusStations(projectId);
case "alignment":
return () => flushAlignmentDraft(projectId, routeId, fromRouteId);
case "culvertOptions":
return () => flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId));
case "uphill":
return () => flushRouteParts(projectId); // 종단 보기 몫과 같은 본문(463)
}
}
type SavedPipes = Awaited<ReturnType<typeof fetchDetailPipePoints>>["pipe_points"];
/** 세션 관 초안이 있으면 노선 저장 전 정본 관(id · 자리) — 없으면 · 못 받으면 null(얹기 안 함). */
const savedPipesIfDraft = (projectId: string): Promise<SavedPipes | null> =>
readPendingPipes(projectId)
? fetchDetailPipePoints(projectId)
.then((detail) => detail.pipe_points)
.catch(() => null)
: Promise.resolve(null);
/** 노선을 저장함 — 서버가 관을 새 노선으로 따라 옮김 · 새 교차 관을 놓음. 세션 관 초안(옛 노선 누가거리일 수
* 있음)을 그 위에 얹어 둠 — 관 차례가 서버 따라감을 덮지 않게(349-0u). 못 받으면 초안 그대로. */
async function rebasePendingPipes(projectId: string, before: SavedPipes): Promise<void> {
const draft = readPendingPipes(projectId);
const after = await fetchDetailPipePoints(projectId).catch(() => null);
if (!draft || !after) return;
const moved = after.pipe_points.map(({ lonlat: _lonlat, ...pipe }) => pipe);
writePendingPipes(projectId, rebasePipeDraft(draft, before, moved));
}
type SavedStructures = Awaited<ReturnType<typeof fetchStructures>>["structures"];
/** 세션 구조물 초안이 있으면 노선 저장 전 정본 — 없으면 · 못 받으면 null(얹기 안 함 · 459). */
const savedStructuresIfDraft = (projectId: string): Promise<SavedStructures | null> =>
readPendingStructures(projectId)
? fetchStructures(projectId)
.then((list) => list.structures)
.catch(() => null)
: Promise.resolve(null);
/** 노선을 저장함 — 서버가 구조물을 좌표로 따라 옮김. 세션 구조물 초안을 그 위에 얹음(459 · 관 짝) — 구조물
* 차례가 서버 따라감을 브라우저 누가거리로 덮지 않게. 못 받으면 초안 그대로. */
async function rebasePendingStructures(projectId: string, before: SavedStructures): Promise<void> {
const draft = readPendingStructures(projectId);
const after = await fetchStructures(projectId).catch(() => null);
if (!draft || !after) return;
writePendingStructures(projectId, rebaseStructureDraft(draft, before, after.structures));
}
/**
* [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다.
* 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true.
*/
export async function saveDesign(
ctx: DesignSaveContext,
options: { confirm?: boolean } = {},
): Promise<boolean> {
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;
/** 횡단 초안을 새 노선으로 넘기기 시작함 — 실패 길에서 두 번 넘기지 않게(누가거리 칸은 제자리에서 옮김). */
let carried = false;
showLoadingOverlay();
saving.add(ctx.projectId);
try {
await ctx.prepare?.();
for (const step of DESIGN_SAVE_ORDER) {
try {
if (step === "route") {
const body = routeBody;
const [before, beforeStructures] = await Promise.all([
savedPipesIfDraft(ctx.projectId),
savedStructuresIfDraft(ctx.projectId),
]);
routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림)
if (routeSaved.route_id != null) routeId = routeSaved.route_id;
if (before) await rebasePendingPipes(ctx.projectId, before);
if (beforeStructures) await rebasePendingStructures(ctx.projectId, beforeStructures);
const outside = (routeSaved.pipes?.outside ?? 0) + (routeSaved.structures?.outside ?? 0);
if (outside) showToast(outsideNotice(outside), "warning");
if (routeSaved.follow_failed?.length)
showToast(
`${routeSaved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`,
"error",
);
continue;
}
if (step === "cross") {
carried = true;
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
if (routeSaved) showToast(routeSaveSummary(routeSaved, outside), "info");
continue;
}
// 상단측은 옛 누가거리 열쇠 — 노선이 바뀌면 횡단 차례(`carryAfterRouteSave`)가 새 측점으로 옮긴 뒤 보냄(443-2)
if (step === "uphill" && routeSaved) continue;
// 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로.
await defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm)();
} catch (error) {
throw stepFailed(step, error); // 그 차례에서 멈춤 · 이름째 알림(429 ⑨)
}
}
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림
if (routeSaved) {
// 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움.
if (confirm)
showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info");
else await ctx.done?.(false);
await rebuildAfterRouteSave(ctx.projectId);
return true;
}
await ctx.done?.(confirm);
return true;
} catch (error) {
if (routeSaved)
await keepDraftsOnNewRoute(ctx.projectId, fromRouteId, routeId, carried).catch(
() => undefined, // 못 옮김 — 초안은 옛 칸에 남음(알림은 아래 한 줄)
);
ctx.failed(error, confirm);
return false;
} finally {
saving.delete(ctx.projectId);
hideLoadingOverlay();
}
/** 계획고 조각 — 노선이 바뀌면 노선 초안 칸(`DRAFT_ROUTE_ID`) ?? 옛 노선 칸의 초안(서버가 새 노선 번호로 고쳐
* 씀 · 측점이 없는 자리는 버리고 알림 · 213-2) · 저장 뒤 두 칸 다 비움. */
function alignmentPart(routeId: number, moved: boolean): SavePart | null {
const draft = (moved ? pendingAlignment(DRAFT_ROUTE_ID) : null) ?? pendingAlignment(routeId);
if (!draft) return null;
return {
body: { route_id: routeId, ...draft },
after: (response) => {
if (response) noticeDroppedEdits(response);
markAlignmentSaved(routeId);
if (moved) markAlignmentSaved(DRAFT_ROUTE_ID);
},
};
}
/** 한 본문 기본 조각 — 세션 초안 그대로(옛 기본 창구와 같은 본문). */
@@ -347,30 +131,23 @@ async function defaultPart(
projectId: string,
routeId: number,
confirm: boolean,
moved: boolean,
): Promise<SavePart | null> {
switch (key) {
case "settings":
case "settings": // 노선 뒤 — 노선 저장이 새로 만든 종단에도 경고 기준이 얹힘(448)
return pageSettingsPart(projectId);
case "pipes":
return pipesPart(projectId, confirm);
case "structures":
return structuresPart(projectId);
case "plusStations":
case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2)
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 "alignment":
return alignmentPart(routeId, moved);
case "culvertOptions":
return culvertOptionsPart(stateKey("culvertopt", projectId, routeId));
case "uphill":
if (moved) return null; // 노선이 바뀌면 손 상단측은 안 보냄 · 새 지형이 정함(요구 274 · 횡단 조각이 비움)
return {
body: await routePartsBody(projectId),
after: () => clearState("uphill", projectId),
@@ -387,33 +164,68 @@ function oneBodyError(error: unknown): Error {
return new Error(`${where}실패 — 아무것도 저장하지 않았습니다(모두 그대로). ${error.message}`);
}
/** 노선 안 바뀐 [저장] · [확정] — 조각을 모아 서버 한 창구에 한 번(472-6 6-A). 서버가 다 저장한 뒤에만 조각
* 정리(초안 비움 · 화면 맞춤) · 실패면 초안 그대로(다음 [저장]에서 다시). */
async function saveOneBody(
/** 노선 저장 결과 알림 — 노선 밖 관 · 구조물 · 따라감 실패 · 결과 한 줄(관 지킴 · 노선 밖 고침 …). */
function noticeRouteSaved(saved: RouteReplanResponse, carryOutside: number): void {
const outside = (saved.pipes?.outside ?? 0) + (saved.structures?.outside ?? 0);
if (outside) showToast(outsideNotice(outside), "warning");
if (saved.follow_failed?.length)
showToast(
`${saved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`,
"error",
);
showToast(routeSaveSummary(saved, carryOutside), "info");
}
/**
* [저장] · [확정] — 세션에 쌓인 조작을 서버 한 창구에 한 번(472-6). 노선이 없으면 아무 일도 하지 않고 false ·
* 다 저장하면 true. 서버가 다 저장한 뒤에만 조각 정리(초안 비움 · 화면 맞춤) · 실패면 초안 그대로.
*/
export async function saveDesign(
ctx: DesignSaveContext,
confirm: boolean,
routeId: number,
options: { confirm?: boolean } = {},
): Promise<boolean> {
const confirm = options.confirm ?? false;
const routeId = ctx.routeId();
if (routeId === null) return false;
showLoadingOverlay();
saving.add(ctx.projectId);
try {
const route: RouteSaveBody | null = await pendingRouteSave(ctx.projectId);
await ctx.prepare?.();
const parts: Partial<Record<SavePartKey, SavePart | null>> = {};
// 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀
const carried = route ? routeCarryPart(ctx.projectId, routeId) : null;
for (const key of SAVE_PART_KEYS) {
if (key === "cross") {
parts.cross = await ctx.crossPart(confirm);
parts.cross = carried ? carried.cross : await ctx.crossPart(confirm);
continue;
}
const base = await defaultPart(key, ctx.projectId, routeId, confirm);
const own = ctx.parts?.[key];
const base = await defaultPart(key, ctx.projectId, routeId, confirm, !!route);
const own = route ? undefined : ctx.parts?.[key];
parts[key] = own ? await own(base, confirm) : base;
}
const result = await postDesignSave(ctx.projectId, routeId, confirm, parts);
const result = await postDesignSave(
ctx.projectId,
routeId,
confirm,
parts,
route ? { route, carry: carried?.carry } : {},
);
if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info");
if (route) routeSaved(ctx.projectId); // 가게 노선 초안 · 조회 결과 캐시 · 편집기 「저장됨」
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);
if (!route) {
await ctx.done?.(confirm);
return true;
}
const saved = (result.steps.route ?? {}) as unknown as RouteReplanResponse;
noticeRouteSaved(saved, Number(result.steps.carry?.outside ?? 0));
// 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움.
if (confirm) showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info");
else await ctx.done?.(false);
await rebuildAfterRouteSave(ctx.projectId);
return true;
} catch (error) {
ctx.failed(oneBodyError(error), confirm);
+17 -7
View File
@@ -4,6 +4,7 @@
*
* 차례마다 조각 하나 = 본문(없으면 null · 서버가 그 차례를 건너뜀) + 저장 뒤 정리(초안 비움 · 화면 맞춤).
* 서버는 전부 저장하거나 아무것도 안 바꿈 — 실패면 차례 이름 · 까닭(`restored` 거짓 = 복구 확인 필요).
* 노선 바뀐 [저장](6-B) = 덧 칸 `route`(노선 초안) · `carry`(옛 노선 세션 칸) — 서버가 맨 앞에서 노선 저장 · 다시 얹기.
* ========================================================================== */
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL } from "@config/config_frontend";
@@ -28,6 +29,9 @@ export interface SavePart {
after?: (response: Record<string, unknown> | undefined) => Promise<void> | void;
}
/** 노선 저장 체인 시간 한도 — 옛 `/route/replan` 요청 한도와 같음(`B05_Profile_Api_Replan`). */
const ROUTE_SAVE_TIMEOUT_MS = 15 * 60 * 1000;
const BODY_KEY: Record<SavePartKey, string> = {
settings: "settings",
pipes: "pipes",
@@ -39,37 +43,43 @@ const BODY_KEY: Record<SavePartKey, string> = {
cross: "cross",
};
/** 서버 차례 이름 — 본문 칸 차례 + 노선(6-B · 맨 앞). */
export type SaveStepKey = SavePartKey | "route";
export interface DesignSaveResult {
waited: boolean;
steps: Partial<Record<SavePartKey, Record<string, unknown>>>;
/** 차례별 응답 · `route` = 노선 저장 결과 · `carry` = 넘김(`outside` = 노선 밖으로 버린 고침 수). */
steps: Partial<Record<SaveStepKey | "carry", Record<string, unknown>>>;
}
/** 실패 — 차례 이름(`step`) · 되돌림 됨(`restored`). */
export class DesignSaveError extends Error {
constructor(
message: string,
readonly step: SavePartKey | null,
readonly step: SaveStepKey | null,
readonly restored: boolean,
) {
super(message);
}
}
/** 조각 본문을 모아 한 번에 보냄. */
/** 조각 본문을 모아 한 번에 보냄 · `extra` = 노선 바뀐 [저장] 덧 칸(`route` · `carry`). */
export async function postDesignSave(
projectId: string,
routeId: number,
confirm: boolean,
parts: Partial<Record<SavePartKey, SavePart | null>>,
extra: { route?: unknown; carry?: unknown } = {},
): Promise<DesignSaveResult> {
const body: Record<string, unknown> = { route_id: routeId, confirm };
const body: Record<string, unknown> = { route_id: routeId, confirm, ...extra };
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);
// [확정]은 낡은 배수유역을 다시 셈 — 옛 차례들의 시간 한도 합 만큼 · 노선 저장이 끼면 그 한도(15 분)를 더함
const limit = API_ANALYSIS_TIMEOUT_MS * 3 + (extra.route ? ROUTE_SAVE_TIMEOUT_MS : 0);
const timer = window.setTimeout(() => controller.abort(), limit);
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/design/save`, {
method: "POST",
@@ -82,7 +92,7 @@ export async function postDesignSave(
if (!response.ok)
throw new DesignSaveError(
payload?.message ?? `HTTP ${response.status}`,
(payload?.step as SavePartKey) ?? null,
(payload?.step as SaveStepKey) ?? null,
payload?.restored !== false,
);
return { waited: payload?.waited === true, steps: payload?.steps ?? {} };
+4 -3
View File
@@ -8,13 +8,14 @@
*
* 값은 등록표 키 `structure-pick`(② 설계 초안 · 페이지를 오가도 남음)에 실린다.
* 어느 화면(3D · 종단 알약 · 목록 · 횡단 카드)에서 골라도 같은 값이 적히고, 다른 쪽은
* 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 도 여기 한 값뿐이다.
* 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 은 여기서 내줌(정의 = common_util).
* ========================================================================== */
import { PIPE_MATCH_M } from "../../common_util/common_util_pipe_match";
import { readState, writeState } from "../b_page_state";
/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51;
/** 관 · 구조물 찾는 거리(m) — 정의 = `common_util_pipe_match`(서버 Node 묶음도 같은 값 · 472-6). */
export { PIPE_MATCH_M };
export interface DesignSelection {
chainageM: number | null;
+5 -2
View File
@@ -12,7 +12,8 @@
* has_foundation }] } ·
* cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) ·
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } ·
* berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } } }
* berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } ·
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) } }
* 출력 { results: { [id]: 값 }, available: [id…] }
* — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 ·
* server-calc 묶음과 같은 차례).
@@ -37,11 +38,12 @@ import {
extraWallRows,
structureAreaRows,
} from "../../../B06_Section/B06_Section_Structure_Layouts";
import { carryRunners, type CarryInputs } from "./design_calc_node_carry";
import { followRunners, type FollowInputs } from "./design_calc_node_follow";
type HaulOptions = NonNullable<Parameters<typeof computeHaulPlan>[2]>;
interface CalcInputs extends FollowInputs {
interface CalcInputs extends FollowInputs, CarryInputs {
detail?: SectionDetailResponse;
route_points?: RoutePoint[];
context?: Partial<
@@ -138,6 +140,7 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
const RUNNERS: Record<string, () => unknown> = {
...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①)
...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B)
// 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임.
"structure.layouts": () => {
const areas = structureAreaRows(sections);
@@ -0,0 +1,48 @@
/* =============================================================================
* A00_Common/design/logic/design_calc_node_carry.ts
* 계산 Node 입구(`design_calc_node.ts`)의 노선 바뀐 [저장] 다시 얹기 몫(PLAN 472-6 6-B) — 브라우저가 쓰던 같은 TS.
*
* facility.rebase 입력 rebase { pipes?: { draft · before · after } · structures?: { draft · before · after } } →
* { pipes · structures }(세션 초안을 서버가 따라 옮긴 정본 위에 · `rebasePipeDraft` ·
* `rebaseStructureDraft` · 없는 몫 null)
* stations.carry 입력 carry(`RouteCarryInput` — 새 노선 상세 · 옛 노선 세션 칸 · 옛 열쇠 목록 · 측점 주인 ·
* 관 세션 장부 · 저장 관 앞뒤) → { patches · outside }(`routeCarryPatches`)
* ========================================================================== */
import {
rebasePipeDraft,
rebaseStructureDraft,
type FollowingPoint,
type FollowingStructure,
} from "@util/common_util_route_follow";
import {
routeCarryPatches,
type RouteCarryInput,
} from "../../../B06_Section/B06_Section_Route_Carry_Patch";
type Pipe = FollowingPoint & { id?: string | null };
type Structure = FollowingStructure & { structure_id?: string | null };
type Rebase<T> = { draft: T[]; before: T[]; after: T[] };
export interface CarryInputs {
rebase?: { pipes?: Rebase<Pipe> | null; structures?: Rebase<Structure> | null };
carry?: RouteCarryInput;
}
/** id → 셈(입구 `RUNNERS` 에 얹음). */
export function carryRunners(inputs: CarryInputs): Record<string, () => unknown> {
return {
"facility.rebase": () => {
const rebase = inputs.rebase;
if (!rebase) return null;
const { pipes, structures } = rebase;
return {
pipes: pipes ? rebasePipeDraft(pipes.draft, pipes.before, pipes.after) : null,
structures: structures
? rebaseStructureDraft(structures.draft, structures.before, structures.after)
: null,
};
},
"stations.carry": () => (inputs.carry ? routeCarryPatches(inputs.carry) : null),
};
}
@@ -178,6 +178,20 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
mirror: ["test_346_2_station_swap", "test_272_swap_keeps_canon"],
note: "가게 stations 구독 · 서버는 처음부터 셈 — R6 이 서버와 한 벌이 되면 대부분 걷힘",
},
{
id: "stations.carry",
no: "R9",
label: "노선 바뀜 횡단 고침 넘김(측점 열쇠 → 새 측점 patch)",
inputs: ["stations.key", "session:crossdesign", "session:revetx"],
outputs: ["[저장] 횡단 patch(새 노선)"],
site: "browser_now",
stage: null,
ts: `${B06L}B06_Section_Route_Carry.ts#carryKeyedStates`,
runner: "node:stations.carry",
pair: null,
mirror: ["test_472_6_route_save", "test_417_route_carry", "test_443_2_uphill_carry"],
note: "브라우저 = 끌기 동안 같은 노선 목록 갈림(`carryStationKeys`) · 서버 = 노선 바뀐 [저장](`B06_Section_Route_Carry_Patch.routeCarryPatches` · 상단측 안 넘김 요구 274)",
},
/* ── 지반 ─────────────────────────────────────────────────────────────── */
{
id: "ground.sample",
@@ -47,6 +47,20 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
mirror: ["test_459_route_seat_mirror", "test_368_2_plus_stations", "test_472_4_follow_node"],
note: "파이썬 `Plus_Stations.follow_plus_stations` = Node 부름 겉(정본 파일 읽기 · 쓰기)",
},
{
id: "facility.rebase",
no: "F10",
label: "관 · 구조물 초안 다시 얹기(노선 바뀐 [저장] — 서버 따라감 위에)",
inputs: ["facility.follow", "input.pipes", "input.structures"],
outputs: ["관 · 구조물 [저장] 본문(새 노선)"],
site: "browser_now",
stage: null,
ts: `${U}common_util_route_follow.ts#rebasePipeDraft`,
runner: "node:facility.rebase",
pair: null,
mirror: ["test_472_6_route_save", "test_349_0u_save_rebase", "test_459_route_seat_mirror"],
note: "구조물 = 같은 파일 `rebaseStructureDraft` · 서버 [저장] 한 본문(`B06_Section_Router_DesignSave_Route`)이 부름",
},
{
id: "facility.stations",
no: "F3",
+3 -27
View File
@@ -231,16 +231,6 @@ export async function confirmRoute(
});
}
/** 세션에 쌓인 상단측(측구 방향) 변경분을 정본으로 내보낸다 — B06 [저장]·[확정]용.
* 3D 램프 클릭은 B05 화면에서만 생기지만 저장 버튼은 B06 에도 있다(B05·B06 은 한 페이지).
* B06 에서 저장하면 이 값이 세션에만 남아 확정 뒤 옛 측구 방향이 그대로 쓰였다
* (2026-09-06 대응표 조사). 비어 있으면 요청을 내지 않는다. */
export async function flushUphillOverrides(projectId: string): Promise<void> {
const overrides = uphillOverridesOf(projectId);
if (!overrides.length) return;
await saveRouteParts(projectId, { uphill_overrides: overrides });
}
/** 세션 상단측(측구 방향) 변경분 — 요청 본문 모양. */
function uphillOverridesOf(
projectId: string,
@@ -251,14 +241,9 @@ function uphillOverridesOf(
.map(([chainage, side]) => ({ chainage_m: Number(chainage), side }));
}
/** [저장] 상단측 차례 본문 한 벌(463 · 요구 271-5) — 노선 캐시 지표 조건 + 확정 지표면 id(서버가 비정규 측점
* 횡단을 맞춤) · 비정규 목록 = 빈 목록(서버가 정본 관 · 구조물에서 셈) · 상단측 = 세션. 종단 보기를 열었든
* 안 열었든 같은 요청(기본 창구 · 종단 보기 몫 둘 다 이것). */
export async function flushRouteParts(projectId: string): Promise<void> {
await saveRouteParts(projectId, await routePartsBody(projectId));
}
/** 상단측 차례 본문 — 노선 캐시 지표 조건 + 확정 지표면 id + 세션 상단측. */
/** [저장] 상단측 차례 본문 한 벌(463 · 요구 271-5 · 472-6 한 본문 `uphill` 칸) — 노선 캐시 지표 조건 + 확정 지표면
* id(서버가 비정규 측점 횡단을 맞춤) · 비정규 목록 = 빈 목록(서버가 정본 관 · 구조물에서 셈) · 상단측 = 세션.
* B06 에서 [저장]해도 세션 상단측이 나감(B05 · B06 은 한 페이지 · 2026-09-06). */
export async function routePartsBody(projectId: string): Promise<RouteConfirmRequest> {
const latest =
readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId).catch(() => null));
@@ -273,15 +258,6 @@ export async function routePartsBody(projectId: string): Promise<RouteConfirmReq
};
}
/** [저장] 차례의 확정(단계 전이 없음) — 하위 차례가 실패하면 던짐(443 · 저장 한 줄기가 그 차례에서 멈춤 ·
* 세션 그대로). 다 되면 보낸 상단측 세션을 비움 — 안 비우면 매 [저장]이 옛 값을 다시 보냄. */
export async function saveRouteParts(projectId: string, body: RouteConfirmRequest): Promise<void> {
const saved = await confirmRoute(projectId, body, false);
const failed = saved.failed_parts ?? [];
if (failed.length) throw new Error(`${failed.join(" · ")} 실패`);
if (body.uphill_overrides?.length) clearState("uphill", projectId);
}
/** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */
export interface RouteResetResponse {
status: string;
@@ -14,7 +14,6 @@ import { draftProjectId, useDesignStore } from "../A00_Common/design/design_stor
import { onPageHistory } from "./B05_Profile_UI_Profile_History";
import {
readRouteLatestCache,
savePageSettings,
writeRouteLatestCache,
type PageSettingsBody,
} from "./B05_Profile_Api_Fetch";
@@ -66,15 +65,6 @@ 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);
@@ -13,7 +13,6 @@
* ========================================================================== */
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";
@@ -28,15 +27,6 @@ export function writePendingPipes(projectId: string, next: PendingPipes | null):
useDesignStore(projectId, null).set("pipes", next);
}
/** 초안이 있으면 정본에 쓰고 비운다 — 관 정본 PUT 은 이 한 번뿐(433-7 · 배수판 메모리는 안 보냄). 빈 목록 =
* 사용자가 다 지움. 초안이 없으면 [저장]은 아무 일도 안 함 · [확정]은 `points` 없이(서버 저장분으로 다시 셈). */
export async function flushPendingPipes(projectId: string, confirm = false): Promise<void> {
const pending = readPendingPipes(projectId);
if (!pending && !confirm) return;
await saveDetailPipePoints(projectId, pending, confirm);
writePendingPipes(projectId, null);
}
/** 한 본문 [저장] 조각(472-6) — 초안 = `{points}` · 초안 없는 [확정] = 빈 본문(서버 저장분으로 다시 셈) ·
* 초안 없는 [저장] = null. 저장 뒤 초안 비움. */
export function pipesPart(projectId: string, confirm: boolean): SavePart | null {
@@ -124,16 +124,6 @@ export function followPlusDraft(projectId: string, next: FollowedDraft | null |
else followedOnly.set(projectId, JSON.stringify(readPlusDraft(projectId)));
}
/** [저장] 차례 — 사용자 초안이 있으면 정본에 씀 · 없으면(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 {
-12
View File
@@ -383,18 +383,6 @@ export function writePendingStructures(projectId: string, next: StructureInstanc
useDesignStore(projectId, null).set("structures", next);
}
/**
* 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다 — 정본이 옛 정의 판이면 그대로 다시 씀(323-9).
*/
export async function flushPendingStructures(projectId: string): Promise<void> {
const pending = readPendingStructures(projectId);
if (!pending && !definitionStale(projectId)) return;
const stored = await fetchStructures(projectId);
await saveStructures(projectId, stored.revision, pending ?? stored.structures); // 고침 없음 = 판 도장만
writePendingStructures(projectId, null);
}
/** 한 본문 [저장] 조각(472-6) — 초안(없고 정의 판이 낡았으면 저장분 = 판 도장) · 판번호는 지금 서버 값 ·
* 초안도 낡음도 없으면 null · 저장 뒤 초안 비움. `list` = 보기가 쥔 목록(초안 없을 때 · 구조물 보기). */
export async function structuresPart(
+46 -70
View File
@@ -2,34 +2,31 @@
* B05_Profile_UI_RouteEdit_Apply.ts
* 노선 편집(평면)의 **노선 저장 이음**(346-3) — [노선 반영] 단추는 없앰.
*
* · 페이지 [저장](`design_save`)이 맨 앞에서 `pendingRouteSave` 로 지금 노선 초안을 받아 `saveRouteDraft`
* (`POST /route/replan` · draft = 계약 `RouteDraft`)로 보낸다 — 노선이 마지막 저장 그대로면 null(안 보냄).
* · 저장 뒤 `carryAfterRouteSave` 가 세션 횡단 초안을 측점 열쇠로 새 노선에 넘기고(`carryStationDrafts`)
* 넘긴 고침을 저장 · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`).
* · 페이지 [저장](`design_save`)이 `pendingRouteSave` 로 지금 노선 초안을 받아 한 본문(`POST /design/save` ·
* `route` 칸 = `RouteSaveBody`)에 싣는다 — 노선이 마지막 저장 그대로면 null(안 보냄 · 472-6 6-B).
* · 서버가 노선 저장 → 관 · 구조물 다시 얹기 → 세션 횡단 고침 넘김(`routeCarryPart` 가 낸 옛 노선 칸 · 같은 TS)까지
* 한 번에 · 다 저장한 뒤 `routeSaved` · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`).
* · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정).
* 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나).
* 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽).
* ========================================================================== */
import { clearDrafts, clearResults, readState } from "../A00_Common/b_page_state";
import { clearDrafts, clearResults, clearState, readState } from "../A00_Common/b_page_state";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import { useDesignStore } from "../A00_Common/design/design_store";
import { commitRouteFacilities } from "../A00_Common/design/design_pipeline_page";
import { currentRoute, remountRoute } from "../A00_Common/router";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { routeCarryInputs } from "../B06_Section/B06_Section_Station_Keys";
import {
invalidateSectionDetail,
loadSectionDetail,
saveCachedCrossPatches,
} from "../B06_Section/B06_Section_Section_Store";
import {
carryChainageStates,
carryStationDrafts,
useStationKeys,
} from "../B06_Section/B06_Section_Station_Keys";
import { withCarriedEdits, withDraftAdjusts } from "../B06_Section/B06_Section_Section_Adjusts";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids";
import { clearRouteLatestCache, flushUphillOverrides } from "./B05_Profile_Api_Fetch";
sectionConfirmBody,
type StandardCrossSection,
} from "../B06_Section/B06_Section_Api_Fetch";
import { readStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import type { SavePart } from "../A00_Common/design/design_save_api";
import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch";
import {
replanRoute,
type RouteReplanResponse,
@@ -120,18 +117,24 @@ export async function pendingRouteSave(projectId: string): Promise<RouteSaveBody
return draft && draft.nodes.length >= 2 ? { draft } : null;
}
/** 노선 초안 저장 — 실패하면 throw(서버가 되살림 · 화면 초안 그대로). 새 노선 번호 · 결과를 돌려줌. */
/** 노선 초안 저장(추천 [적용]) — 실패하면 throw(서버가 되살림 · 화면 초안 그대로). 새 노선 번호 · 결과를 돌려줌. */
export async function saveRouteDraft(
projectId: string,
body: RouteSaveBody,
): Promise<RouteReplanResponse> {
pipesBeforeSave.set(projectId, await savedPipes(projectId));
const saved = await replanRoute(projectId, body);
routeSaved(projectId);
return saved;
}
/** 노선을 저장함(서버가 다 저장한 뒤) — 묶음의 초안 전 관 · 구조물 버림(초안 비움을 「버림」으로 되돌리지 않게 ·
* 저장한 초안 자리가 정본) · 가게 노선 초안 비움 · 조회 결과 캐시 비움(옛 노선 것 · 추천은 새 노선의 출발점이라
* 다시 적음) · 편집기 「저장됨」. */
export function routeSaved(projectId: string): void {
commitRouteFacilities(projectId);
useDesignStore(projectId, null).set("routeDraft", null);
// 노선이 바뀌면 조회 결과 캐시는 옛 노선 것 — 추천은 새 노선의 출발점이라 다시 적는다.
clearResults(projectId);
sources.get(projectId)?.saved();
return saved;
}
/** 저장 결과 한 줄 — 관 지킴 · 새 교차 관 · 구조물 옮김 · 멀어짐 · 노선 밖 고침 · 배수유역 낡음. */
@@ -148,59 +151,32 @@ export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): strin
return `노선 저장 — ${parts.join(" · ")}`;
}
type SavedPipe = { id?: string; chainage_m: number };
/** 노선 저장 직전 저장 관(id · 옛 누가거리) — 저장 뒤 같은 id 의 새 자리로 조절을 옮김(초안으로 안 따라간 고침 · 417). */
const pipesBeforeSave = new Map<string, SavedPipe[]>();
const savedPipes = (projectId: string): Promise<SavedPipe[]> =>
fetchDetailPipePoints(projectId)
.then((res) => res.pipe_points)
.catch(() => []);
/**
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠 · 누가거리 열쇠 조절 · 417) → 새 노선으로 넘기고 그 고침을 저장 —
* 초안을 통째로 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
* `save` 거짓 = 넘기기만(뒤 차례 실패 · 433-12 — 다음 [저장]이 새 노선 초안을 보냄).
* 노선 바뀐 [저장]의 횡단 조각(472-6 6-B) — 옛 노선 세션 고침 원본(측점 열쇠 칸 · 누가거리 칸 · 열쇠 목록 · 관 장부)을
* `carry` 로 · 횡단 본문 = 표준 횡단면 · 풍선 자리(옛 `saveCachedCrossPatches` 와 같은 칸). 새 측점 patch 는 서버가
* 같은 TS 로 셈(Node `stations.carry`) · 고침이 없어도 한 번 나감(429 ④). 손 상단측은 안 보냄(요구 274 · 새 지형이
* 정함) — 다 저장한 뒤 세션 상단측 · 관 종류 바꿈 자리를 비우고 버린 상단측 수를 알림.
*/
export async function carryAfterRouteSave(
export function routeCarryPart(
projectId: string,
fromRouteId: number,
toRouteId: number,
{ save = true }: { save?: boolean } = {},
): Promise<number> {
invalidateSectionDetail(projectId, toRouteId);
const detail = await loadSectionDetail(projectId, toRouteId);
useStationKeys(projectId, toRouteId, detail);
// 관 열쇠 → 새 자리 — 세션 id(`pipe-s3`) = 관 장부 · 저장 관 id(`p8017`) = 서버가 옮긴 관
const saved = await savedPipes(projectId);
const before = pipesBeforeSave.get(projectId) ?? [];
pipesBeforeSave.delete(projectId);
const newAt = (id: string | undefined): number | null =>
saved.find((pipe) => id !== undefined && pipe.id === id)?.chainage_m ?? null;
const pipeAt = (key: string): number | null => {
const id = key.startsWith("pipe:") ? key.slice(5) : null;
if (!id) return null;
return id.startsWith("pipe-") ? pipeChainageOf(id, projectId) : newAt(id);
): { carry: Record<string, unknown>; cross: SavePart } {
const standard = readStandardCrossSession(projectId) as StandardCrossSection | null;
const uphill = Object.keys(readState("uphill", projectId) ?? {}).length;
return {
carry: routeCarryInputs(projectId, fromRouteId),
cross: {
body: sectionConfirmBody(standard ?? undefined, undefined, {
balloon_offsets: balloonOffsetsPayload(),
}),
after: () => {
clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4)
clearState("uphill", projectId);
if (uphill)
showToast(`상단측 손 고침 ${uphill}곳 — 노선이 바뀌어 새 지형으로 정했습니다.`, "info");
},
},
};
// 옛 저장 관 자리에 남은 고침(초안으로 안 따라감) = 같은 관 id 의 새 자리
const movedAt = (chainageM: number): number | null =>
newAt(before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id);
const uphillCount = (): number => Object.keys(readState("uphill", projectId) ?? {}).length;
const uphillBefore = uphillCount();
const outside =
carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true }) +
carryChainageStates(projectId, fromRouteId, toRouteId, { keyAt: pipeAt, movedAt });
// 상단측 = 새 측점으로 옮긴 것만 횡단 패치 앞에 보냄(443-2 · 옛 누가거리로 엉뚱한 측점에 안 얹힘)
const uphillDropped = uphillBefore - uphillCount();
if (uphillDropped > 0)
showToast(`상단측 ${uphillDropped}곳 — 새 노선에 같은 측점이 없어 뺐습니다.`, "info");
if (!save) return outside;
await flushUphillOverrides(projectId);
// 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417)
withDraftAdjusts(detail, projectId, toRouteId);
withCarriedEdits(detail, projectId, toRouteId);
await saveCachedCrossPatches(projectId, toRouteId);
return outside;
}
/** 저장 뒤 페이지를 새로고침 없이 다시 세운다 — 편집기가 걸어 둔 길(옛 노선 캐시 비움 포함) · 없으면 라우터. */
+4 -13
View File
@@ -31,6 +31,10 @@ import {
} 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";
import { STRUCTURE_ADJUST_KEYS } from "./logic/B06_Section_Design_Keys";
/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 목록 한 벌 = `logic/B06_Section_Design_Keys`(472-6). */
export { STRUCTURE_ADJUST_KEYS };
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로.
* 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */
@@ -101,19 +105,6 @@ export function culvertOptionsPart(sessionKey: string | null): SavePart | null {
return { body: pending, after: () => writePending(sessionKey, {}) };
}
/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */
export const STRUCTURE_ADJUST_KEYS = [
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
] as const;
/** 측점 조정값 세션 — 열쇠가 누가거리(`b` · `:역할` 붙은 것 포함)로 시작함. */
const STATION_ADJUST_STATES: readonly StateName[] = [
"culvertopt",
+4 -37
View File
@@ -43,6 +43,7 @@ import {
type BermSpec,
} from "@util/common_util_cross_berm";
import { readState } from "../A00_Common/b_page_state";
import { USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys";
import { savedDesigns } from "./B06_Section_Section_Canon";
import {
crossDesignChoices,
@@ -89,44 +90,10 @@ export interface CrossRefreshInput {
shouldApply?: () => boolean;
}
/**
* 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로
* 실어 보낸다(`B06_Section_Section_Store`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS`
* 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`).
*
* 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을
* 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
*/
export const USER_TOUCHED_KEYS = [
"display_half_width_m",
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
// 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9).
"berm",
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
"cut_slope_ratio_user",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
// 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님.
"structure_note_cad",
// [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
"detail_table",
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
"shoulder_extend_left_m",
"shoulder_extend_right_m",
// 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김
"berm_depth_left_m",
"berm_depth_right_m",
] as const;
/** 계산이 만들지 않는 **사용자 값** — 목록 한 벌 = `logic/B06_Section_Design_Keys`(서버 Node 입구도 · 472-6). */
export { USER_TOUCHED_KEYS };
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
/**
@@ -0,0 +1,436 @@
/* =============================================================================
* B06_Section_Route_Carry_Patch.ts
* 세션 고침 → 상세 설계 칸 얹기 · [저장] 횡단 patch 꼴 **한 벌**(472-6 6-B) — 화면 · 세션 · fetch 없음.
*
* · `layDraftAdjusts` · `layCarriedEdits` — 조정창 · 옮긴 고침 칸(읽는 함수를 받음 · 브라우저 세션 겉 =
* `B06_Section_Section_Adjusts`) · `layPipeOptions` — 관 옵션의 조정값 몫(387-4b).
* · `crossPatchesFromCache` — 캐시 설계의 사용자 칸 = [저장] patch 바탕(445 · 상세 가게가 내줌).
* · `routeCarryPatches` — 노선 바뀐 [저장]: 옛 노선 세션 칸 → 새 노선 서버 상세(측점 열쇠 넘김
* `logic/B06_Section_Route_Carry`) → 얹기 → patch(옛 브라우저 길 `carryAfterRouteSave` 와 같은 차례) ·
* 서버 Node 입구 `stations.carry` 가 부름(`A00_Common/design/logic/design_calc_node_carry.ts`).
* logic 밖 = 구조물 그림 기하(관 벽 · 단 · 구간 · 세월 · BOX 기본값)를 끔 — 서버 Node 묶음 알려진 끌림.
* ========================================================================== */
import type {
CrossDesign,
CrossSectionPatch,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom";
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import { PIPE_MATCH_M } from "../common_util/common_util_pipe_match";
import { inferStationInterval } from "../common_util/common_util_svg";
import { applySpanPatch, WALL_MIN_PURE_HEIGHT_M } from "./logic/B06_Section_UI_Cross_Culvert_Const";
import { tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeStoredLayouts } from "./B06_Section_Structure_Layouts";
import { ROW_CARRY_KEYS } from "./logic/B06_Section_Station_Swap";
import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches";
import { STRUCTURE_ADJUST_KEYS, USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys";
import {
carryChainageStates,
carryKeyedStates,
readKeyedIn,
stationKeysFrom,
type Keyed,
type KeyedState,
type Owner,
type Raw,
} from "./logic/B06_Section_Route_Carry";
export type Cells = Record<string, unknown>;
/** 누가거리(숫자) → 쪽마다 값(세션에 있는 쪽만 · 노견 확장 · 소단 깊이). */
export type SideValues = ReadonlyMap<number, Partial<Record<"left" | "right", number>>>;
type Obj = Record<string, unknown>;
const INLET = ["auto", "revet", "I", "L", "U"];
const isObj = (value: unknown): value is Obj => !!value && typeof value === "object";
const count = (value: unknown): number =>
Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0;
/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */
function wallAdjust(value: unknown): WallAdjust | null {
if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value };
if (!isObj(value)) return null;
const partial = value as Partial<WallAdjust>;
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
}
/** 저장 안 한 조정창 값(누가거리 열쇠 칸 · `read`)을 상세 설계 칸에 얹음 — 조정 4축 · 형식 · 단 수 · 구간 · 연동 ·
* 세월교 · BOX(387-4 · 브라우저 세션 = `withDraftAdjusts` · 서버 = 노선 바뀐 [저장] 넘김 · 472-6). */
export function layDraftAdjusts(
detail: SectionDetailResponse,
read: (name: string) => Cells,
): void {
const revet = read("revetx");
const basin = read("basinadjust");
const inlet = read("inletstruct");
const extra = read("extrawall");
const spans = read("extraspan");
const ford = read("fordadjust");
const box = read("boxadjust");
const link = read("revetlink") as { detached?: Cells; grade?: Cells };
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
const own = (cells: Cells): Array<[string, unknown]> =>
Object.entries(cells)
.filter(([key]) => key.startsWith(`${at}:`))
.map(([key, value]) => [key.slice(at.length + 1), value]);
for (const [role, value] of own(revet)) {
const adjust = wallAdjust(value);
if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust };
else if (value === null && design.revet_adjust?.[role]) {
// null = 되돌림 — 저장분 그 벽을 뺌(445 M2)
const { [role]: _gone, ...rest } = design.revet_adjust;
design.revet_adjust = rest;
}
}
if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string))
design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"];
if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] };
// 세션에 있는 쪽만 — 없는 쪽은 저장분 그대로(445 H3)
const saved = design.extra_wall_counts;
if (at in extra || `b${at}` in extra)
design.extra_wall_counts = {
outlet: at in extra ? count(extra[at]) : (saved?.outlet ?? 0),
basin: `b${at}` in extra ? count(extra[`b${at}`]) : (saved?.basin ?? 0),
};
for (const [wall, value] of own(spans)) {
const span = value as { lengthM: number; beforeM: number; afterM: number } | null;
if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue;
design.extra_spans = {
...(design.extra_spans ?? {}),
[wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM },
};
}
if (isObj(link.detached) && at in link.detached)
design.revet_link_detached = !!link.detached[at];
if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at];
if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]);
const sides = box[at] as { left?: Obj; right?: Obj } | undefined;
if (isObj(sides))
design.box_adjust = {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right },
};
}
}
/** 노선 저장 뒤 새 노선으로 옮긴 세션 고침(417) — 사면 쉬프트 · 소단 깊이 · 측점 폭 · 메모 · 상세 표를 설계 칸에 얹음 ·
* 같은 [저장]의 횡단 패치(`crossPatchesFromCache`)가 실음. 조절 4축 · 형식은 `layDraftAdjusts`(브라우저 세션 =
* `withCarriedEdits`). */
export function layCarriedEdits(
detail: SectionDetailResponse,
read: (name: string) => Cells,
sideValues: { shoulder: SideValues; berm: SideValues },
): void {
const cells: Array<[string, Cells]> = [
["display_half_width_m", read("crossw")],
["structure_note", read("structure-notes")],
["structure_note_cad", read("structure-note-cads")],
["detail_table", read("detail-table")],
];
const sides: Array<[string, SideValues]> = [
["shoulder_extend", sideValues.shoulder],
["berm_depth", sideValues.berm],
];
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
for (const [field, values] of cells) if (values[at] !== undefined) design[field] = values[at];
for (const [field, values] of sides) {
const own = values.get(Number(at));
for (const side of ["left", "right"] as const)
if (own?.[side] !== undefined) design[`${field}_${side}_m`] = own[side];
}
}
}
/**
* 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름).
*
* 초기 계산값은 원복용으로 서버에 그대로 있고, 사용자가 만진 값은 이 캐시 한 세트가
* 최종본이다. B05 [임시저장]도 이 세트를 함께 보내야 B06에서 만진 구조물 조정이
* 영구저장소에 남는다 — 안 보내면 캐시만 비워져 편집이 사라진다.
*
* 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)를 측점마다 **통째로** 싣는다 — [저장] patch 의 바탕
* (445 H3 · 세션 고침은 그 위에 `mergeCrossPatches`). `savedAt`(서버 행 누가거리)을 주면 그 자리 행이 없는
* 측점(옮긴 관 · 구조물 · 새 측점 = 서버가 기본 설계로 새로 세움)은 지반 등 입력(`ROW_CARRY_KEYS`)도 실음(445 H2).
*/
export function crossPatchesFromCache(
detail: SectionDetailResponse,
kindChanged: readonly number[] = [],
savedAt?: readonly number[],
): CrossSectionPatch[] {
const patches: CrossSectionPatch[] = [];
// 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값은 서버가 비움 · 캐시에 남은 옛 값을 다시 안 실음
const structureKeys: readonly string[] = STRUCTURE_ADJUST_KEYS;
const reset = (at: number) => kindChanged.some((c) => Math.abs(c - at) < PIPE_MATCH_M);
const rowless = (at: number) => !!savedAt && savedAt.every((c) => Math.abs(c - at) >= 0.01);
for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true });
for (const section of detail.cross_sections) {
const design = section.design;
if (!design) continue;
const patch: CrossSectionPatch = { chainage_m: section.chainage_m };
const skip = reset(section.chainage_m);
let touched = false;
// 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고
// 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이
// 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
const source = design as unknown as Record<string, unknown>;
const target = patch as unknown as Record<string, unknown>;
const keys = rowless(section.chainage_m)
? [...USER_TOUCHED_KEYS, ...ROW_CARRY_KEYS]
: USER_TOUCHED_KEYS;
for (const key of keys) {
const value = source[key];
if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue;
target[key] = value;
touched = true;
}
if (touched) patches.push(patch);
}
return patches;
}
/** 관 하나 — 자리 · 폼 칸(관 초안 · 저장 관 `pipe_points` 같은 꼴). */
export interface OptionPipe {
chainage_m: number;
options?: Record<string, unknown> | null;
}
const isDefault = (a: WallAdjust): boolean =>
Math.abs(a.x) < 1e-9 && a.d == null && a.h == null && a.m == null;
const round1 = (value: number): number => Math.round(value * 10) / 10;
/** 조정창 `update` 와 같은 꼴 — x · d 는 그대로 · h · m 만(폼 한 칸이 주인 · 빈 칸 = 자동 null). */
function setWall(design: CrossDesign, role: string, h: number | null, m: string | null): void {
const current = { ...ZERO_ADJUST, ...(design.revet_adjust?.[role] ?? {}) } as WallAdjust;
const next: WallAdjust = { ...current, h: h === null ? null : round1(h), m };
const bucket = { ...(design.revet_adjust ?? {}) };
if (isDefault(next)) delete bucket[role];
else bucket[role] = next;
design.revet_adjust = bucket;
}
/** 관 옵션의 조정값 몫을 상세에 얹음(폼 칸 전부 = 되보내기와 같은 규칙) — 같은 값이면 그대로. */
export function layPipeOptions(
detail: SectionDetailResponse,
pipes: ReadonlyArray<OptionPipe>,
): void {
const sections = detail.cross_sections;
for (const pipe of pipes) {
const options = pipe.options;
if (!options) continue;
const near = (at: number): boolean => Math.abs(at - pipe.chainage_m) < PIPE_MATCH_M;
// 물넘이포장 = 제원만(66-2) · 벽 없음
if (sections.some((s) => s.ford_pavement && near(s.ford_pavement.chainage_m ?? s.chainage_m)))
continue;
const owner = sections.find((s) => near(s.chainage_m));
const culvert = owner?.culvert;
const design = owner?.design;
if (!owner || !culvert || !design) continue;
const num = (key: string): number | undefined => {
const value = Number(options[key]);
return Number.isFinite(value) ? value : undefined;
};
for (const role of ["inlet", "outlet"] as const) {
const form = options[`${role}_revet_form`];
setWall(
design,
role,
num(`${role}_revet_height_m`) ?? null,
typeof form === "string" ? form : null,
);
}
if (culvert.hidden_pipe && !owner.ford && !owner.box) continue;
// 흙막이 n 단 = 지형이 세운 유출 다단(`pipeExtraCount` 와 같은 셈)
const outlet = design.extra_wall_counts?.outlet ?? 0;
const probe = {
...owner,
design: { ...design, extra_wall_counts: { basin: 0, ...design.extra_wall_counts, outlet } },
};
const tiers = outlet
? (computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet)
: 0;
for (let n = 1; n <= tiers; n++) {
const role = `extra${n - 1}`;
const value = (item: string): number | undefined => {
const raw = options[`soil_add_${n}_${item}`];
return raw === undefined || raw === "" || !Number.isFinite(Number(raw))
? undefined
: Number(raw);
};
const form = options[`soil_add_${n}_form`];
const height = value("height");
setWall(
design,
role,
height === undefined ? null : Number(Math.max(height, WALL_MIN_PURE_HEIGHT_M).toFixed(1)),
typeof form === "string" && form ? form : null,
);
const span = { lengthM: value("length"), beforeM: value("before"), afterM: value("after") };
if (Object.values(span).every((v) => v === undefined)) continue;
const at = tierSpanOf(owner, role);
const current = {
lengthM: Math.round((at.beforeM + at.afterM) * 10) / 10,
beforeM: Math.round(at.beforeM * 10) / 10,
afterM: Math.round(at.afterM * 10) / 10,
};
const next = applySpanPatch(current, span);
design.extra_spans = {
...(design.extra_spans ?? {}),
[role]: { length_m: next.lengthM, before_m: next.beforeM, after_m: next.afterM },
};
}
}
}
/** 노선 바뀐 [저장] 넘김 입력(브라우저 `routeCarryInputs` + 서버 저장분 · 472-6 6-B). */
export interface RouteCarryInput {
/** 새 노선 서버 상세 — 관 · 구조물 · 추가 측점 · 계획고 차례 뒤(옛 길 `loadSectionDetail` 과 같은 때). */
detail: SectionDetailResponse;
/** 옛 노선 세션 칸 원본 — 측점 열쇠 칸 다섯 · 누가거리 칸 · `kindreset`(상단측 뺌 · 요구 274). */
states: Record<string, unknown>;
/** 옛 노선 측점 열쇠 목록(브라우저가 세운 것 · 없으면 null). */
from_keys: Keyed[] | null;
/** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */
owners: Owner[];
/** 관 세션 장부(`pipe-ids`). */
pipe_ids: { next: number; ids: Array<{ id: string; at: number }> } | null;
/** 노선 저장 직전 저장 관(id · 누가거리). */
pipes_before: Array<{ id?: string | null; chainage_m: number }>;
/** 관 차례 뒤 저장 관(id · 누가거리 · 옵션). */
pipes_after: Array<OptionPipe & { id?: string | null }>;
}
/** 관 세션 장부 사본 — 찾기 · 없으면 새 차례(브라우저 `pipeIdNear` · `pipeIdAt` · `pipeChainageOf` 와 같은 규칙). */
function pipeBook(book: RouteCarryInput["pipe_ids"]) {
const ids = [...(book?.ids ?? [])];
let next = book?.next ?? 1;
const near = (at: number): string | null => {
let best: { id: string; at: number } | undefined;
let gap = PIPE_MATCH_M;
for (const entry of ids)
if (Math.abs(entry.at - at) < gap) [best, gap] = [entry, Math.abs(entry.at - at)];
return best?.id ?? null;
};
return {
near,
at: (at: number): string => {
const hit = near(at);
if (hit) return hit;
const id = `pipe-s${next++}`;
ids.push({ id, at });
return id;
},
chainageOf: (id: string): number | null => {
if (!id.startsWith("pipe-")) return null;
const hit = ids.find((entry) => entry.id === id);
if (hit) return hit.at;
const legacy = Number(id.slice("pipe-".length));
return Number.isFinite(legacy) ? legacy : null;
},
};
}
/** 누가거리(0.01 m 글) 열쇠 맵 → 누가거리(숫자) 맵(값이 있는 것만). */
function byChainage<V>(cells: ReadonlyMap<string, V>): Map<number, V> {
const out = new Map<number, V>();
for (const [key, value] of cells) {
const chainage = Number(key);
if (Number.isFinite(chainage) && value) out.set(chainage, value);
}
return out;
}
/** 카드 선택 한 벌(세션 `crossdesign` · `CrossDesignChoice` 꼴). */
interface CardChoice {
ground_type?: string;
section_mode?: string;
ditch_side?: string | null;
ditch_type?: string | null;
paved?: boolean;
two_stage_slope?: boolean;
ditch_choice?: boolean | null;
}
type Sides = Partial<Record<"left" | "right", number>>;
/**
* 노선 바뀐 [저장] 횡단 patch — 옛 브라우저 길(`carryAfterRouteSave` → `saveCachedCrossPatches`)과 같은 차례:
* 새 측점 열쇠 → 관 옵션 얹기 → 측점 열쇠 칸 · 누가거리 칸 넘김 → 조정 · 옮긴 고침 얹기 → 캐시 사용자 칸 바탕 +
* 카드 선택 · 암선 · 암 경사. `outside` = 노선 밖으로 버린 고침 수(측점 단위). `detail` 을 제자리에서 고침.
*/
export function routeCarryPatches(input: RouteCarryInput): {
patches: CrossSectionPatch[];
outside: number;
} {
const { detail } = input;
const sections = detail.cross_sections;
const book = pipeBook(input.pipe_ids);
const policy = (
detail.longitudinal?.profile_alignment as
{ policy?: { station_interval_m?: number } } | undefined
)?.policy;
const interval = policy?.station_interval_m ?? inferStationInterval(sections);
const from = input.from_keys ?? undefined;
const to = stationKeysFrom(sections, interval, input.owners, book);
layPipeOptions(detail, input.pipes_after);
const states = input.states;
const keyed = carryKeyedStates((name) => (states[name] as Raw | undefined) ?? {}, from, to);
const newAt = (id: string | null | undefined): number | null =>
input.pipes_after.find((pipe) => id != null && pipe.id === id)?.chainage_m ?? null;
const chain = carryChainageStates(
(name) => (states[name] as Record<string, unknown> | undefined) ?? null,
from,
to,
{
keyAt: (key) => {
const id = key.startsWith("pipe:") ? key.slice(5) : null;
if (!id) return null;
return id.startsWith("pipe-") ? book.chainageOf(id) : newAt(id);
},
movedAt: (chainageM) =>
newAt(input.pipes_before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id),
},
);
const cells = (name: string): Cells => chain.states[name] ?? {};
const read = <V>(name: KeyedState) => readKeyedIn<V>(keyed.states[name], to);
layDraftAdjusts(detail, cells);
layCarriedEdits(detail, cells, {
shoulder: byChainage(read<Sides>("shoulderext")),
berm: byChainage(read<Sides>("bermdepth")),
});
const kindChanged = Object.keys(chain.states.kindreset ?? {}).map(Number);
const savedAt = sections.map((section) => section.chainage_m);
const base = crossPatchesFromCache(detail, kindChanged, savedAt);
// 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 옛 `sessionOverlay` 와 같은 칸(445 H5)
const overlay: CrossSectionPatch[] = [];
byChainage(read<CardChoice>("crossdesign")).forEach((choice, chainage) => {
const patch: Record<string, unknown> = {
chainage_m: chainage,
ground_type: choice.ground_type,
section_mode: choice.section_mode,
};
if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side;
if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type;
if (choice.paved !== undefined) patch.paved = choice.paved;
if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope;
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
overlay.push(patch as unknown as CrossSectionPatch);
});
for (const [chainage, offset] of read<unknown>("rockb"))
if (typeof offset === "number")
overlay.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset });
for (const [chainage, ratio] of read<number>("cutslope"))
overlay.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio });
const patches = mergeCrossPatches(
base,
overlay.filter((patch) => Number.isFinite(patch.chainage_m)),
);
return { patches, outside: keyed.outside.size + chain.outside.size };
}
+58 -15
View File
@@ -8,7 +8,10 @@
(오류 응답 · 측점 맞춤 실패 · 재계산 실패 · 하위 차례 실패) DB 행 · storage 를 시작 전으로 되돌리고
그 차례 이름 · 까닭을 돌려줌(`common_util_save_guard` · 브레인 A). 되돌림마저 실패하면 「복구 확인
필요」. 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`waited`).
노선 바뀐 저장(노선 초안)은 아직 옛 길(`/route/replan` 뒤 차례 · 6-B).
노선 바뀐 저장(`route` · `carry` 칸 · 6-B) = 맨 앞 노선 저장 → 관 · 구조물 초안 다시 얹기 →
위 차례(새 노선) · 횡단 = 옛 노선 세션 칸을 새 측점에 넘긴 patch · 상단측 안 보냄(요구 274) ·
늘 [저장](새 노선을 본 뒤 [확정]) · 되돌림 범위에 배수 폴더 · 지표면 확정(설계 지형 바꿈)까지
(`B06_Section_Router_DesignSave_Route`).
"""
from __future__ import annotations
@@ -25,12 +28,14 @@ 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_Route_Save import RouteSaveRequest
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_DesignSave_Route import RouteCarryRequest, StepFailed
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
@@ -60,12 +65,10 @@ class DesignSaveRequest(BaseModel):
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
#: 노선 초안(`/route/replan` 본문 꼴) — 오면 노선 바뀐 저장(6-B)
route: RouteSaveRequest | None = None
#: 노선 바뀐 저장의 옛 노선 세션 칸(측점 열쇠 넘김 입력)
carry: RouteCarryRequest | None = None
def _body(response: Any) -> dict[str, Any]:
@@ -105,6 +108,9 @@ async def _run_steps(
from B06_Section.B06_Section_Router_Stations import put_plus_stations
out: dict[str, Any] = {}
route_id = request.route_id
moved = request.route is not None
before: dict[str, Any] = {}
async def step(name: str, call: Any) -> dict[str, Any]:
try:
@@ -119,6 +125,24 @@ async def _run_steps(
raise StepFailed(name, "시험 갈래 — 이 차례 뒤 일부러 실패")
return out[name]
if moved:
from B05_Profile.B05_Profile_Router_Replan import replan_route
from B06_Section import B06_Section_Router_DesignSave_Route as moving
before = await moving.saved_facilities(project_id)
routed = await step("route", replan_route(project_id, request.route))
route_id = int(routed["route_id"])
# 관 · 구조물 초안 = 서버가 따라 옮긴 정본 위에(Node · 옛 브라우저 `rebasePipeDraft` 짝)
try:
await moving.rebase_drafts(project_id, request, before)
except StepFailed:
raise
except Exception as error:
logger.exception("한 본문 저장 다시 얹기 실패: project_id=%s", project_id)
raise StepFailed("route", str(error) or type(error).__name__) from None
# 계획고 초안 = 새 노선에(기하 이월 · 서버가 없는 자리 버림)
if request.alignment is not None:
request.alignment = request.alignment.model_copy(update={"route_id": route_id})
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:
@@ -140,17 +164,34 @@ async def _run_steps(
await step(
"culvertOptions", put_pipe_points(project_id, {"points": points}, session, False)
)
if request.uphill is not None:
if request.uphill is not None and not moved: # 노선 바뀜 = 손 상단측 안 넘김(요구 274)
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:
# 노선 바뀜 = 옛 노선 세션 칸 → 새 측점 patch(Node) · 고침이 없어도 한 번(429 ④)
if moved:
try:
carried = await moving.carry_cross(project_id, route_id, request.carry, before)
except StepFailed:
raise
except Exception as error:
logger.exception("한 본문 저장 횡단 넘김 실패: project_id=%s", project_id)
raise StepFailed("cross", str(error) or type(error).__name__) from None
out["carry"] = {"outside": int(carried.get("outside") or 0)}
body = request.cross.model_dump() if request.cross is not None else {}
body["cross_patches"] = carried.get("patches") or None
# 늘 [저장] — [확정]은 새 노선을 본 뒤 다시(옛 길 그대로)
crossing = save_sections(project_id, route_id, SectionConfirmRequest.model_validate(body))
elif 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", "구조물 측점 맞춤 실패")
crossing = save(project_id, request.route_id, request.cross)
else:
return out
cross = await step("cross", crossing)
if cross.get("recomputed") is False:
raise StepFailed("cross", "서버 재계산 실패")
if cross.get("stations_synced") is False:
raise StepFailed("cross", "구조물 측점 맞춤 실패")
return out
@@ -167,9 +208,11 @@ async def save_design(
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))
moved = request.route is not None # 노선 저장 = 설계 지형 바꿈(지표면 확정)도 떠 둠
rows = await guard.snapshot_rows(connection, str(project_id), surface=moved)
root = Path(resolve_stored_project_path(stored_path))
pipes = request.pipes is not None or bool(request.culvert_options) or request.confirm
pipes = pipes or moved # 노선 저장 = 관 따라감 · 새 교차 관(배수 폴더)
files = guard.snapshot_storage(
root, guard.STORAGE_SCOPE + (guard.PIPE_SCOPE if pipes else ())
)
@@ -0,0 +1,123 @@
"""노선 바뀐 [저장] 한 본문 몫(PLAN 472-6 6-B) — `B06_Section_Router_DesignSave` 가 부름.
노선 초안이 오면 차례 맨 앞:
노선 저장(`/route/replan` 체인 · 관 · 구조물 · 추가 측점 좌표 따라감 · 종횡단 · 계획고 ·
횡단 이월)
→ 관 · 구조물 초안 다시 얹기(Node `facility.rebase` · 서버 따라감 위에)
→ 6-A 차례(새 노선) → 횡단 = 옛 노선 세션 칸을 새 측점에 넘긴 patch(Node `stations.carry`)
브라우저가 하던 다시 얹기(`rebasePipeDraft` · `carryAfterRouteSave`)와 같은 TS 를 서버가 부름 —
브라우저는 노선 초안 + 사용자 칸만 보냄. 손 상단측은 안 넘김(요구 274 · 새 지형이 정함).
"""
from __future__ import annotations
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from common_util.common_util_calc_runner import run_calc
class RouteCarryRequest(BaseModel):
"""옛 노선 세션 칸 — 브라우저 `routeCarryInputs` 꼴 그대로(넘김 규칙은 TS 한 벌)."""
model_config = ConfigDict(extra="forbid")
#: 칸 이름 → 세션 원본(측점 열쇠 칸 다섯 · 누가거리 칸 · `kindreset`)
states: dict[str, Any] = Field(default_factory=dict)
#: 옛 노선 측점 열쇠 목록 [{key, chainageM}] · 브라우저가 못 세웠으면 null
from_keys: list[dict[str, Any]] | None = None
#: 측점을 덮는 관 · 구조물 [{id, kind, at, role}]
owners: list[dict[str, Any]] = Field(default_factory=list)
#: 관 세션 장부 {next, ids: [{id, at}]}
pipe_ids: dict[str, Any] | None = None
class StepFailed(Exception):
def __init__(self, step: str, message: str) -> None:
super().__init__(message)
self.step = step
def _node(ids: list[str], inputs: dict[str, Any], step: str) -> dict[str, Any]:
out = run_calc(ids, inputs)
if not isinstance(out, dict) or ids[0] not in out:
raise StepFailed(step, "다시 얹기 계산(Node)을 못 돌렸습니다")
return out[ids[0]]
def _pipes(listing: Any) -> list[dict[str, Any]]:
"""관 GET 응답 → 관 목록(지도용 lonlat 뺌 · 브라우저 `fetchDetailPipePoints` 짝)."""
points = listing.get("pipe_points") if isinstance(listing, dict) else None
return [{k: v for k, v in point.items() if k != "lonlat"} for point in points or []]
async def saved_facilities(project_id: UUID) -> dict[str, Any]:
"""노선 저장 직전 저장 관 · 구조물(다시 얹기 · 넘김의 `before`)."""
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points
from B05_Profile.B05_Profile_Structures_Router import read_structures
pipes = await get_pipe_points(project_id)
structures = await read_structures(project_id)
return {
"pipes": _pipes(pipes),
"structures": [
item.model_dump(mode="json") for item in getattr(structures, "structures", None) or []
],
}
async def rebase_drafts(project_id: UUID, request: Any, before: dict[str, Any]) -> None:
"""관 · 구조물 초안을 서버가 따라 옮긴 정본 위에(Node `facility.rebase`) — 본문을 제자리에서
고침.
구조물 판번호 = 노선 저장 뒤 지금 판(따라감이 판을 올림 · 옛 길 = 저장 뒤 받은 판).
"""
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points
from B05_Profile.B05_Profile_Structures_Router import read_structures
rebase: dict[str, Any] = {}
points = (request.pipes or {}).get("points") if isinstance(request.pipes, dict) else None
if points is not None:
after = _pipes(await get_pipe_points(project_id))
rebase["pipes"] = {"draft": points, "before": before["pipes"], "after": after}
stored = None
if request.structures is not None:
stored = await read_structures(project_id)
draft = [item.model_dump(mode="json") for item in request.structures.structures]
after = [item.model_dump(mode="json") for item in stored.structures]
rebase["structures"] = {"draft": draft, "before": before["structures"], "after": after}
if not rebase:
return
out = _node(["facility.rebase"], {"rebase": rebase}, "route")
if out.get("pipes") is not None:
request.pipes = {**request.pipes, "points": out["pipes"]}
if out.get("structures") is not None and stored is not None:
request.structures = type(request.structures).model_validate(
{"base_revision": stored.revision, "structures": out["structures"]}
)
async def carry_cross(
project_id: UUID,
route_id: int,
carry: RouteCarryRequest | None,
before: dict[str, Any],
) -> dict[str, Any]:
"""옛 노선 세션 칸 → 새 노선 횡단 patch(Node `stations.carry`) → {patches, outside}."""
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points
from B06_Section.B06_Section_Router import get_section_detail
detail = await get_section_detail(project_id, route_id)
if not hasattr(detail, "model_dump"):
raise StepFailed("cross", "새 노선 횡단 상세를 못 읽었습니다")
inputs = {
"detail": detail.model_dump(mode="json"),
"pipes_before": before["pipes"],
"pipes_after": _pipes(await get_pipe_points(project_id)),
**(carry or RouteCarryRequest()).model_dump(mode="json"),
}
return _node(["stations.carry"], {"carry": inputs}, "cross")
+15 -195
View File
@@ -13,93 +13,25 @@
* ========================================================================== */
import { readState } from "../A00_Common/b_page_state";
import type { CrossDesign, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom";
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { applySpanPatch, WALL_MIN_PURE_HEIGHT_M } from "./logic/B06_Section_UI_Cross_Culvert_Const";
import { tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeStoredLayouts } from "./B06_Section_Structure_Layouts";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { readBermDepths, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
import {
layCarriedEdits,
layDraftAdjusts,
layPipeOptions,
type Cells,
type OptionPipe,
} from "./B06_Section_Route_Carry_Patch";
type Cells = Record<string, unknown>;
type Obj = Record<string, unknown>;
const INLET = ["auto", "revet", "I", "L", "U"];
const isObj = (value: unknown): value is Obj => !!value && typeof value === "object";
const count = (value: unknown): number =>
Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0;
/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */
function wallAdjust(value: unknown): WallAdjust | null {
if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value };
if (!isObj(value)) return null;
const partial = value as Partial<WallAdjust>;
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
}
// 얹기 몸통 = `B06_Section_Route_Carry_Patch` 한 벌(서버 노선 바뀐 [저장] 넘김도 · 472-6) — 여기는 세션 겉.
export { layPipeOptions, type OptionPipe };
export function withDraftAdjusts(
detail: SectionDetailResponse,
projectId: string,
routeId: number,
): void {
const read = (name: Parameters<typeof readState>[0]): Cells =>
readState<Cells>(name, projectId, routeId) ?? {};
const revet = read("revetx");
const basin = read("basinadjust");
const inlet = read("inletstruct");
const extra = read("extrawall");
const spans = read("extraspan");
const ford = read("fordadjust");
const box = read("boxadjust");
const link = read("revetlink") as { detached?: Cells; grade?: Cells };
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
const own = (cells: Cells): Array<[string, unknown]> =>
Object.entries(cells)
.filter(([key]) => key.startsWith(`${at}:`))
.map(([key, value]) => [key.slice(at.length + 1), value]);
for (const [role, value] of own(revet)) {
const adjust = wallAdjust(value);
if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust };
else if (value === null && design.revet_adjust?.[role]) {
// null = 되돌림 — 저장분 그 벽을 뺌(445 M2)
const { [role]: _gone, ...rest } = design.revet_adjust;
design.revet_adjust = rest;
}
}
if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string))
design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"];
if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] };
// 세션에 있는 쪽만 — 없는 쪽은 저장분 그대로(445 H3)
const saved = design.extra_wall_counts;
if (at in extra || `b${at}` in extra)
design.extra_wall_counts = {
outlet: at in extra ? count(extra[at]) : (saved?.outlet ?? 0),
basin: `b${at}` in extra ? count(extra[`b${at}`]) : (saved?.basin ?? 0),
};
for (const [wall, value] of own(spans)) {
const span = value as { lengthM: number; beforeM: number; afterM: number } | null;
if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue;
design.extra_spans = {
...(design.extra_spans ?? {}),
[wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM },
};
}
if (isObj(link.detached) && at in link.detached)
design.revet_link_detached = !!link.detached[at];
if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at];
if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]);
const sides = box[at] as { left?: Obj; right?: Obj } | undefined;
if (isObj(sides))
design.box_adjust = {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right },
};
}
layDraftAdjusts(detail, (name) => readState<Cells>(name as never, projectId, routeId) ?? {});
}
/** 노선 저장 뒤 새 노선으로 옮긴 세션 고침(417) — 사면 쉬프트 · 소단 깊이 · 측점 폭 · 메모 · 상세 표를 설계 칸에 얹음 ·
@@ -109,29 +41,10 @@ export function withCarriedEdits(
projectId: string,
routeId: number,
): void {
const read = (name: Parameters<typeof readState>[0]): Cells =>
readState<Cells>(name, projectId, routeId) ?? {};
const cells: Array<[string, Cells]> = [
["display_half_width_m", read("crossw")],
["structure_note", read("structure-notes")],
["structure_note_cad", read("structure-note-cads")],
["detail_table", read("detail-table")],
];
const sides: Array<[string, ReturnType<typeof readShoulderExtends>]> = [
["shoulder_extend", readShoulderExtends(projectId, routeId)],
["berm_depth", readBermDepths(projectId, routeId)],
];
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
for (const [field, values] of cells) if (values[at] !== undefined) design[field] = values[at];
for (const [field, values] of sides) {
const own = values.get(Number(at));
for (const side of ["left", "right"] as const)
if (own?.[side] !== undefined) design[`${field}_${side}_m`] = own[side];
}
}
layCarriedEdits(detail, (name) => readState<Cells>(name as never, projectId, routeId) ?? {}, {
shoulder: readShoulderExtends(projectId, routeId),
berm: readBermDepths(projectId, routeId),
});
}
/** 관 옵션 조정값 몫 얹기(387-4b) — 관 초안 ?? 저장 관(페이지 자료 줄). 가게 · 자료 줄은 부를 때 늦게 불러옴 —
@@ -158,96 +71,3 @@ export async function withPipeOptions(
.catch(() => null);
if (pipes) layPipeOptions(detail, pipes as OptionPipe[]);
}
/** 관 하나 — 자리 · 폼 칸(관 초안 · 저장 관 `pipe_points` 같은 꼴). */
export interface OptionPipe {
chainage_m: number;
options?: Record<string, unknown> | null;
}
const isDefault = (a: WallAdjust): boolean =>
Math.abs(a.x) < 1e-9 && a.d == null && a.h == null && a.m == null;
const round1 = (value: number): number => Math.round(value * 10) / 10;
/** 조정창 `update` 와 같은 꼴 — x · d 는 그대로 · h · m 만(폼 한 칸이 주인 · 빈 칸 = 자동 null). */
function setWall(design: CrossDesign, role: string, h: number | null, m: string | null): void {
const current = { ...ZERO_ADJUST, ...(design.revet_adjust?.[role] ?? {}) } as WallAdjust;
const next: WallAdjust = { ...current, h: h === null ? null : round1(h), m };
const bucket = { ...(design.revet_adjust ?? {}) };
if (isDefault(next)) delete bucket[role];
else bucket[role] = next;
design.revet_adjust = bucket;
}
/** 관 옵션의 조정값 몫을 상세에 얹음(폼 칸 전부 = 되보내기와 같은 규칙) — 같은 값이면 그대로. */
export function layPipeOptions(
detail: SectionDetailResponse,
pipes: ReadonlyArray<OptionPipe>,
): void {
const sections = detail.cross_sections;
for (const pipe of pipes) {
const options = pipe.options;
if (!options) continue;
const near = (at: number): boolean => Math.abs(at - pipe.chainage_m) < PIPE_MATCH_M;
// 물넘이포장 = 제원만(66-2) · 벽 없음
if (sections.some((s) => s.ford_pavement && near(s.ford_pavement.chainage_m ?? s.chainage_m)))
continue;
const owner = sections.find((s) => near(s.chainage_m));
const culvert = owner?.culvert;
const design = owner?.design;
if (!owner || !culvert || !design) continue;
const num = (key: string): number | undefined => {
const value = Number(options[key]);
return Number.isFinite(value) ? value : undefined;
};
for (const role of ["inlet", "outlet"] as const) {
const form = options[`${role}_revet_form`];
setWall(
design,
role,
num(`${role}_revet_height_m`) ?? null,
typeof form === "string" ? form : null,
);
}
if (culvert.hidden_pipe && !owner.ford && !owner.box) continue;
// 흙막이 n 단 = 지형이 세운 유출 다단(`pipeExtraCount` 와 같은 셈)
const outlet = design.extra_wall_counts?.outlet ?? 0;
const probe = {
...owner,
design: { ...design, extra_wall_counts: { basin: 0, ...design.extra_wall_counts, outlet } },
};
const tiers = outlet
? (computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet)
: 0;
for (let n = 1; n <= tiers; n++) {
const role = `extra${n - 1}`;
const value = (item: string): number | undefined => {
const raw = options[`soil_add_${n}_${item}`];
return raw === undefined || raw === "" || !Number.isFinite(Number(raw))
? undefined
: Number(raw);
};
const form = options[`soil_add_${n}_form`];
const height = value("height");
setWall(
design,
role,
height === undefined ? null : Number(Math.max(height, WALL_MIN_PURE_HEIGHT_M).toFixed(1)),
typeof form === "string" && form ? form : null,
);
const span = { lengthM: value("length"), beforeM: value("before"), afterM: value("after") };
if (Object.values(span).every((v) => v === undefined)) continue;
const at = tierSpanOf(owner, role);
const current = {
lengthM: Math.round((at.beforeM + at.afterM) * 10) / 10,
beforeM: Math.round(at.beforeM * 10) / 10,
afterM: Math.round(at.afterM * 10) / 10,
};
const next = applySpanPatch(current, span);
design.extra_spans = {
...(design.extra_spans ?? {}),
[role]: { length_m: next.lengthM, before_m: next.beforeM, after_m: next.afterM },
};
}
}
}
+7 -104
View File
@@ -18,7 +18,6 @@
* ========================================================================== */
import { clearState, readState, readStateRaw, writeState } from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station";
import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations";
import {
@@ -29,11 +28,14 @@ import {
} from "./B06_Section_Section_Drafts";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import { withDraftAdjusts, withPipeOptions } from "./B06_Section_Section_Adjusts";
import { crossPatchesFromCache } from "./B06_Section_Route_Carry_Patch";
// [저장] patch 바탕 = 한 벌(서버 노선 바뀐 [저장] 넘김도 · 472-6 6-B)
export { crossPatchesFromCache };
import type {
CrossSection,
CrossSectionPatch,
SectionDetailResponse,
StandardCrossSection,
} from "./B06_Section_Api_Fetch";
import {
forgetCanon,
@@ -45,9 +47,9 @@ import {
savedDesigns,
sessionDetail,
} from "./B06_Section_Section_Canon";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
import { fetchSectionDetail } from "./B06_Section_Api_Fetch";
import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options";
import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options";
import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types";
import type { DesignStoreValues } from "../A00_Common/design/design_store";
import { useDesignStore } from "../A00_Common/design/design_store";
@@ -60,16 +62,8 @@ import {
type Keyed,
type KeyedState,
} from "./B06_Section_Station_Keys";
import {
ROW_CARRY_KEYS,
sameStationList,
swapCrossSections,
} from "./logic/B06_Section_Station_Swap";
import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap";
import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches";
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
import { readCutSlopeSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { emitSwap, type DetailListener } from "./B06_Section_Swap_Bus";
export { onAnySwap } from "./B06_Section_Swap_Bus";
@@ -466,53 +460,6 @@ export function peekSectionDetail(
return routeId === null ? null : (cache.get(keyOf(projectId, routeId)) ?? null);
}
/**
* 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름).
*
* 초기 계산값은 원복용으로 서버에 그대로 있고, 사용자가 만진 값은 이 캐시 한 세트가
* 최종본이다. B05 [임시저장]도 이 세트를 함께 보내야 B06에서 만진 구조물 조정이
* 영구저장소에 남는다 — 안 보내면 캐시만 비워져 편집이 사라진다.
*
* 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)를 측점마다 **통째로** 싣는다 — [저장] patch 의 바탕
* (445 H3 · 세션 고침은 그 위에 `mergeCrossPatches`). `savedAt`(서버 행 누가거리)을 주면 그 자리 행이 없는
* 측점(옮긴 관 · 구조물 · 새 측점 = 서버가 기본 설계로 새로 세움)은 지반 등 입력(`ROW_CARRY_KEYS`)도 실음(445 H2).
*/
export function crossPatchesFromCache(
detail: SectionDetailResponse,
kindChanged: readonly number[] = [],
savedAt?: readonly number[],
): CrossSectionPatch[] {
const patches: CrossSectionPatch[] = [];
// 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값은 서버가 비움 · 캐시에 남은 옛 값을 다시 안 실음
const structureKeys: readonly string[] = STRUCTURE_ADJUST_KEYS;
const reset = (at: number) => kindChanged.some((c) => Math.abs(c - at) < PIPE_MATCH_M);
const rowless = (at: number) => !!savedAt && savedAt.every((c) => Math.abs(c - at) >= 0.01);
for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true });
for (const section of detail.cross_sections) {
const design = section.design;
if (!design) continue;
const patch: CrossSectionPatch = { chainage_m: section.chainage_m };
const skip = reset(section.chainage_m);
let touched = false;
// 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고
// 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이
// 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
const source = design as unknown as Record<string, unknown>;
const target = patch as unknown as Record<string, unknown>;
const keys = rowless(section.chainage_m)
? [...USER_TOUCHED_KEYS, ...ROW_CARRY_KEYS]
: USER_TOUCHED_KEYS;
for (const key of keys) {
const value = source[key];
if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue;
target[key] = value;
touched = true;
}
if (touched) patches.push(patch);
}
return patches;
}
/**
* [저장] · [확정] 횡단 patch(445) — 바탕 = 캐시 설계의 사용자 칸(측점마다 통째 · 서버 행이 없는 측점은 지반 등
* 입력까지) 위에 세션 고침(`overlay` · 벽 · 단 dict 는 열쇠마다 합침 · 안쪽 null = 되돌림). 캐시가 없으면 세션 고침만.
@@ -529,47 +476,3 @@ export function cachedCrossPatches(
: [];
return mergeCrossPatches(base, overlay);
}
/** 노선 저장 뒤 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 횡단 보기 [저장] 본문과 같은 칸(445 H5). */
function sessionOverlay(projectId: string, routeId: number): CrossSectionPatch[] {
const out: CrossSectionPatch[] = [];
crossDesignChoices(projectId, routeId).forEach((choice, chainage) => {
const patch: CrossSectionPatch = {
chainage_m: chainage,
ground_type: choice.ground_type,
section_mode: choice.section_mode,
};
if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side;
if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type;
if (choice.paved !== undefined) patch.paved = choice.paved;
if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope;
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
out.push(patch);
});
const rock = readRockBoundarySession(projectId, routeId) ?? {};
for (const [chainage, offset] of Object.entries(rock))
if (typeof offset === "number")
out.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset });
for (const [chainage, ratio] of Object.entries(readCutSlopeSession(projectId, routeId)))
out.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio });
return out.filter((patch) => Number.isFinite(patch.chainage_m));
}
/**
* 노선 저장 뒤 고침을 영구저장소에 남긴다 — 횡단 보기 [저장]과 같은 본문(캐시 사용자 칸 · 카드 선택 · 암선 ·
* 암 경사 · 표준 횡단면 · 풍선 자리 · 445 H5). 수정분이 없어도 한 번 보냄 — 서버가 지금 종단으로 횡단을 다시 셈
* (429 ④ · 노선 뒤에 나간 계획고 초안). 저장 실패는 호출자가 처리한다.
*/
export async function saveCachedCrossPatches(projectId: string, routeId: number): Promise<number> {
const patches = cachedCrossPatches(projectId, routeId, sessionOverlay(projectId, routeId));
const standard = readStandardCrossSession(projectId) as StandardCrossSection | null;
await saveSections(
projectId,
routeId,
standard ?? undefined,
patches.length ? patches : undefined,
{ balloon_offsets: balloonOffsetsPayload() },
);
clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4)
return patches.length;
}
+69 -225
View File
@@ -22,76 +22,33 @@ import { renameChainageKeys } from "./logic/B06_Section_Station_Swap";
import { afterAnySwap } from "./B06_Section_Swap_Bus";
import { pipeIdAt, pipeIdNear } from "../A00_Common/design/design_pipe_ids";
import { showToast } from "@ui/ui_template_elements";
import {
chainageKey,
curveMoves,
dedupeStationKeys,
looseChainage,
stationKey,
type StationKeyRole,
} from "@util/common_util_station_key";
import { chainageKey, looseChainage, type StationKeyRole } from "@util/common_util_station_key";
import { inferStationInterval } from "@util/common_util_svg";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import {
carryChainageStates as carryChainageCells,
carryKeyedStates,
chainageIn,
HINT,
keyIn,
KEYED_STATES,
NEAR_M,
nearestKeyed,
rawEntries,
ROUTE_CARRY_STATES,
stationKeysFrom,
type CarryFacilities,
type Keyed,
type KeyedState,
type Owner,
type Raw,
} from "./logic/B06_Section_Route_Carry";
/** 측점 열쇠로 담는 세션 칸. */
export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope";
export const KEYED_STATES: readonly KeyedState[] = [
"crossdesign",
"shoulderext",
"bermdepth",
"rockb",
"cutslope",
];
/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */
const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const;
/** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */
const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope", "shoulderext", "bermdepth"];
/**
* 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸.
* ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS`(측점 열쇠마다) + `STRUCTURE_ADJUST_KEYS`(관 · 구조물 열쇠만) ·
* 거울 시험 `resources/tester/test_417_route_carry_mirror.py`.
*/
export const ROUTE_CARRY_STATES = {
revetx: ["revet_adjust"],
basinadjust: ["basin_adjust"],
inletstruct: ["inlet_structure"],
extrawall: ["extra_wall_counts"],
extraspan: ["extra_spans"],
revetlink: ["revet_link_detached", "revet_follow_grade"],
fordadjust: ["ford_adjust"],
boxadjust: ["box_adjust"],
crossw: ["display_half_width_m"],
"structure-notes": ["structure_note"],
"structure-note-cads": ["structure_note_cad"],
"detail-table": ["detail_table"],
shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"],
bermdepth: ["berm_depth_left_m", "berm_depth_right_m"],
uphill: [], // 상단측(측구 방향) — 프로젝트 칸 · 설계 칸 없음 · 노선 바뀐 저장 때 새 측점으로(443-2)
} as const;
/** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */
const STRUCTURE_STATES = new Set<string>([
"revetx",
"basinadjust",
"inletstruct",
"extrawall",
"extraspan",
"revetlink",
"fordadjust",
"boxadjust",
]);
/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */
const NEAR_M = 0.01;
const HINT = "@at";
export type Keyed = { key: string; chainageM: number };
type Raw = Record<string, unknown> & { [HINT]?: Record<string, number> };
// 넘김 규칙 · 칸 목록 = logic 한 벌(서버 Node 입구도 · 472-6 6-B) — 부르는 쪽은 여기서 그대로 받음
export { KEYED_STATES, ROUTE_CARRY_STATES, type CarryFacilities, type Keyed, type KeyedState };
const lists = new Map<string, Keyed[]>();
/** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */
type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole };
const owners = new Map<string, Owner[]>();
let current: { projectId: string; routeId: number } | null = null;
@@ -145,39 +102,17 @@ export function useStructureIds(
owners.set(projectId, known);
}
const looksLikePipe = (section: CrossSection): boolean =>
Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? "");
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이
* 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */
/** 측점 목록 → 열쇠(규칙 = `stationKeysFrom` · 관 id = 관 세션 장부). */
export function stationKeysOf(
projectId: string | null,
sections: ReadonlyArray<CrossSection>,
intervalM: number | null,
): Keyed[] {
const known = projectId ? (owners.get(projectId) ?? []) : [];
const planned = known.some((owner) => owner.kind === "pipe");
const keys = sections.map((section, index) => {
const at = section.chainage_m;
let key: string;
if (index === 0) key = stationKey("bp", at, intervalM);
else if (index === sections.length - 1) key = stationKey("ep", at, intervalM);
else if (section.kind === "irregular") {
const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M);
if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role);
else if (looksLikePipe(section)) {
// 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E)
const id = planned ? pipeIdNear(at, projectId) : pipeIdAt(at, projectId);
key = id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM);
} else
key = section.grid
? stationKey("regular", at, intervalM)
: stationKey("irregular", at, intervalM);
} else key = stationKey(section.kind, at, intervalM);
return key;
return stationKeysFrom(sections, intervalM, known, {
near: (at) => pipeIdNear(at, projectId),
at: (at) => pipeIdAt(at, projectId),
});
const chainages = sections.map((section) => section.chainage_m);
return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i] }));
}
type KeyedDetail = Pick<SectionDetailResponse, "cross_sections" | "longitudinal">;
@@ -229,22 +164,12 @@ export function registeredKeys(
): string[] {
const list = lists.get(scopeOf(projectId, routeId));
const computed = stationKeysOf(projectId, sections, intervalM);
return sections.map((s, i) => (list && nearest(list, s.chainage_m)?.key) ?? computed[i].key);
}
function nearest(list: Keyed[], chainageM: number): Keyed | undefined {
let best: Keyed | undefined;
for (const entry of list) {
const gap = Math.abs(entry.chainageM - chainageM);
if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry;
}
return best;
return sections.map((s, i) => (list && nearestKeyed(list, s.chainage_m)?.key) ?? computed[i].key);
}
/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */
export function keyAt(projectId: string, routeId: number, chainageM: number): string {
const list = lists.get(scopeOf(projectId, routeId));
return (list && nearest(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`;
return keyIn(lists.get(scopeOf(projectId, routeId)), chainageM);
}
/**
@@ -257,21 +182,7 @@ export function chainageOfKey(
key: string,
hint?: number,
): number | null {
const list = lists.get(scopeOf(projectId, routeId));
const hit = list?.find((entry) => entry.key === key);
if (hit) return Number(chainageKey(hit.chainageM));
const at = looseChainage(key) ?? hint;
if (at === undefined) return null;
if (!list) return Number(chainageKey(at));
const near = nearest(list, at);
return near ? Number(chainageKey(near.chainageM)) : null;
}
/** 두 노선 목록 사이 MC 가 움직인 곡선 열쇠(432-3 · 서버 `carry_cross_inputs` 와 같음). */
function curveMovesBetween(projectId: string, fromRouteId: number, toRouteId: number) {
const pairs = (routeId: number) =>
(lists.get(scopeOf(projectId, routeId)) ?? []).map((e) => [e.key, e.chainageM] as const);
return curveMoves(pairs(fromRouteId), pairs(toRouteId));
return chainageIn(lists.get(scopeOf(projectId, routeId)), key, hint);
}
function readRaw(name: KeyedState, projectId: string, routeId: number): Raw {
@@ -279,13 +190,6 @@ function readRaw(name: KeyedState, projectId: string, routeId: number): Raw {
return raw && typeof raw === "object" ? raw : {};
}
function entries<V>(raw: Raw): Array<[string, V, number | undefined]> {
const hints = raw[HINT] ?? {};
return Object.entries(raw)
.filter(([key]) => key !== HINT)
.map(([key, value]) => [key, value as V, hints[key]]);
}
/**
* 칸 하나를 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌(지우지 않음).
* 옛 열쇠(누가거리 · at:)는 지금 측점 열쇠로 옮겨 다시 씀(목록이 있을 때).
@@ -294,7 +198,7 @@ export function readKeyed<V>(name: KeyedState, projectId: string, routeId: numbe
const raw = readRaw(name, projectId, routeId);
const out = new Map<string, V>();
let moved = false;
for (const [key, value, hint] of entries<V>(raw)) {
for (const [key, value, hint] of rawEntries<V>(raw)) {
const at = chainageOfKey(projectId, routeId, key, hint);
if (at === null) continue;
out.set(chainageKey(at), value);
@@ -319,7 +223,7 @@ export function writeKeyed<V>(
const raw = readRaw(name, projectId, routeId);
const next: Raw = { [HINT]: {} };
const replaced = new Set((only ?? [...byChainage.keys()]).map((at) => chainageKey(Number(at))));
for (const [key, value, hint] of entries<unknown>(raw)) {
for (const [key, value, hint] of rawEntries<unknown>(raw)) {
const at = chainageOfKey(projectId, routeId, key, hint);
if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue;
next[key] = value;
@@ -346,30 +250,12 @@ export function carryStationDrafts(
toRouteId: number,
{ quiet = false }: { quiet?: boolean } = {},
): number {
const outside = new Set<string>();
const curves = curveMovesBetween(projectId, fromRouteId, toRouteId);
for (const name of KEYED_STATES) {
const raw = readRaw(name, projectId, fromRouteId);
const next: Raw = { [HINT]: {} };
for (const [key, value] of entries<unknown>(raw)) {
// 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로
const fromAt =
looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key);
const was = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt);
const stable = curves.get(was) ?? was;
const toAt = chainageOfKey(projectId, toRouteId, stable, looseChainage(stable) ?? undefined);
const carried = carriedValue(name, value);
if (toAt === null || stable.startsWith("at:")) {
if (carried !== null) outside.add(stable);
continue;
}
if (carried === null) continue;
const toKey = keyAt(projectId, toRouteId, toAt);
next[toKey] = carried;
next[HINT]![toKey] = toAt;
}
writeState(name, next, projectId, toRouteId);
}
const { states, outside } = carryKeyedStates(
(name) => readRaw(name, projectId, fromRouteId),
lists.get(scopeOf(projectId, fromRouteId)),
lists.get(scopeOf(projectId, toRouteId)),
);
for (const name of KEYED_STATES) writeState(name, states[name], projectId, toRouteId);
if (outside.size && !quiet)
showToast(`노선 밖 고침 ${outside.size} 건 — 사라진 측점이라 버렸습니다.`, "info");
return outside.size;
@@ -394,28 +280,6 @@ export function carryStationKeys(projectId: string, routeId: number, next: Keyed
return outside;
}
/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */
function carriedValue(name: KeyedState, value: unknown): unknown {
if (CARRY_WHOLE.includes(name)) return value;
if (name !== "crossdesign" || !value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
const picked = Object.fromEntries(
CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]),
);
return Object.keys(picked).length ? picked : null;
}
/** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */
export interface CarryFacilities {
/** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */
movedAt?: (chainageM: number) => number | null | undefined;
/** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */
keyAt?: (key: string) => number | null | undefined;
}
/** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */
const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/;
/**
* 노선 저장 뒤(새 노선 측점 열쇠를 세운 뒤) 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고)을 옛 노선
* → 새 노선 측점으로 옮김 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 ·
@@ -429,62 +293,42 @@ export function carryChainageStates(
toRouteId: number,
facilities: CarryFacilities = {},
): number {
const facilityAt = facilities.keyAt ?? (() => null);
const outside = new Set<string>();
const curves = curveMovesBetween(projectId, fromRouteId, toRouteId);
const moveAt = (state: string, text: string): string | null => {
const moved = facilities.movedAt?.(Number(text));
if (moved !== null && moved !== undefined) return chainageKey(moved);
const was = keyAt(projectId, fromRouteId, Number(text));
const stable = curves.get(was) ?? was;
const structural = stable.startsWith("pipe:") || stable.startsWith("structure:");
// 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점)
const toAt =
stable.startsWith("at:") || (STRUCTURE_STATES.has(state) && !structural)
? null
: chainageOfKey(
projectId,
toRouteId,
stable,
(structural ? facilityAt(stable) : null) ?? Number(text),
);
if (toAt === null) outside.add(stable);
return toAt === null ? null : chainageKey(toAt);
};
const rekey = (state: string, cells: Record<string, unknown>): Record<string, unknown> => {
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cells)) {
const head = CHAINAGE_HEAD.exec(key);
const to = head && moveAt(state, head[2]);
if (head && to !== null) next[`${head[1]}${to}${head[3] ?? ""}`] = value;
}
return next;
};
for (const state of Object.keys(ROUTE_CARRY_STATES)) {
if ((KEYED_STATES as readonly string[]).includes(state)) continue; // 측점 열쇠 칸 = carryStationDrafts
const name = state as StateName;
const raw = readState<Record<string, unknown>>(name, projectId, fromRouteId);
if (!raw || typeof raw !== "object") continue;
// 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹
const next =
state === "revetlink"
? Object.fromEntries(
Object.entries(raw).map(([part, cells]) => [
part,
cells && typeof cells === "object"
? rekey(state, cells as Record<string, unknown>)
: cells,
]),
)
: rekey(state, raw);
writeState(name, next, projectId, toRouteId);
}
// 관 종류 바꿈 자리(318-4 · 프로젝트 칸) — 구조물 조절과 같은 규칙으로 옮김(445 M7)
const resets = readState<Record<string, unknown>>("kindreset", projectId);
if (resets) writeState("kindreset", rekey("revetx", resets), projectId);
const { states, outside } = carryChainageCells(
(name) =>
name === "kindreset"
? readState<Record<string, unknown>>("kindreset", projectId)
: readState<Record<string, unknown>>(name as StateName, projectId, fromRouteId),
lists.get(scopeOf(projectId, fromRouteId)),
lists.get(scopeOf(projectId, toRouteId)),
facilities,
);
for (const [name, next] of Object.entries(states))
if (name === "kindreset") writeState("kindreset", next, projectId);
else writeState(name as StateName, next, projectId, toRouteId);
return outside.size;
}
/** 노선 바뀐 [저장] 본문의 넘김 입력(472-6 6-B) — 서버 Node `stations.carry` 가 같은 규칙으로 새 노선에 얹음.
* 칸 = 측점 열쇠 칸 다섯 · 누가거리 칸(상단측 뺌 · 요구 274) · 관 종류 바꿈 자리 · 옛 노선 열쇠 목록 · 측점 주인 ·
* 관 세션 장부. */
export function routeCarryInputs(projectId: string, fromRouteId: number): Record<string, unknown> {
const states: Record<string, unknown> = {};
for (const name of KEYED_STATES) states[name] = readRaw(name, projectId, fromRouteId);
for (const state of Object.keys(ROUTE_CARRY_STATES)) {
if (state === "uphill" || (KEYED_STATES as readonly string[]).includes(state)) continue;
const raw = readState<Record<string, unknown>>(state as StateName, projectId, fromRouteId);
if (raw && typeof raw === "object") states[state] = raw;
}
const resets = readState<Record<string, unknown>>("kindreset", projectId);
if (resets) states.kindreset = resets;
return {
states,
from_keys: lists.get(scopeOf(projectId, fromRouteId)) ?? null,
owners: owners.get(projectId) ?? [],
pipe_ids: readState("pipe-ids", projectId) ?? null,
};
}
/**
* 측점이 움직임(갈아끼움 · 옛 0.01 m 글 → 새 글) — 조정창 맵이 안 선 누가거리 칸(B05 만 연 페이지 · 새로고침 ·
* 세월 · BOX · 연동)을 세션에서 바로 새 자리로(415-3). 칸 = `ROUTE_CARRY_STATES`(측점 열쇠 칸 빼고) · 조정창
@@ -5,7 +5,7 @@
*
* · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로.
* · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고
* [임시저장]·[확정]의 `flushPendingStructures`가 정본에 쓴다(캐시→저장 원칙).
* [저장]·[확정] 한 본문(`structuresPart`)이 정본에 쓴다(캐시→저장 원칙).
* · 계곡 통과 시설(A군, pipe_points 정본)은 **표시·선택만** — 추가·이동·삭제는
* 배수유역 재분할 체인이 있는 B05 몫이라 안내만 한다(2026-08-29 1차 범위).
* · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설
+1
View File
@@ -21,6 +21,7 @@
- 입구 `B06_Section_Server_Calc_Node.ts`(package.json `build:server-calc`) · 끌어감 = `UI_Cross_Culvert*` · `_Box_Geom` · `_Ford_Geom` · `_Wall*` · `_Revetment` · `_Lean` · `_Excavation` · `_Structure_Cut` · `_Body_Guard` · `Structure_Layouts` · `UI_Section_Common`
- `build:corridor` · `build:b07-extent` 도 `UI_Cross_*` 구조물 그림을 끌어감 — 고치면 해당 build 다시 지어 시험
- `build:design-calc`(A00 `design_calc_node`)가 `Route_Carry_Patch` · `logic/Route_Carry` · `logic/Design_Keys` 를 끌어감(노선 바뀐 [저장] 넘김) — 고치면 다시 지어 `test_472_6_route_save`
- 입구 경로를 시험이 씀(`test_b06_ground_unlock.py` · `test_b06_height_unlock.py` 등) · 묶음이 끄는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지
남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도):
@@ -0,0 +1,55 @@
/* =============================================================================
* B06_Section_Design_Keys.ts
* 횡단 설계 칸 목록 한 벌 — 사용자 값(`USER_TOUCHED_KEYS`) · 구조물 조정값(`STRUCTURE_ADJUST_KEYS`).
* 자료만(브라우저 · 서버 Node 입구 `stations.carry` 가 같은 목록 · 472-6 6-B) · 서버 짝 = 각 목록 주석.
* ========================================================================== */
/**
* 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로
* 실어 보낸다(`B06_Section_Section_Store` · `B06_Section_Route_Carry_Patch`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS`
* 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`).
*
* 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을
* 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
*/
export const USER_TOUCHED_KEYS = [
"display_half_width_m",
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
// 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9).
"berm",
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
"cut_slope_ratio_user",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
// 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님.
"structure_note_cad",
// [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
"detail_table",
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
"shoulder_extend_left_m",
"shoulder_extend_right_m",
// 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김
"berm_depth_left_m",
"berm_depth_right_m",
] as const;
/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */
export const STRUCTURE_ADJUST_KEYS = [
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
] as const;
@@ -0,0 +1,300 @@
/* =============================================================================
* B06_Section_Route_Carry.ts
* 노선이 바뀔 때 횡단 고침 넘김 규칙 **한 벌**(PLAN 344-3 · 417 · 472-6 6-B) — 순수 계산(세션 · 화면 없음).
*
* · 측점 열쇠 목록 = `stationKeysFrom`(일반 grid:k · 관 pipe:<id> · 구조물 structure:<id> · 시종점 end:BP/EP ·
* id 모름 at:<0.01 m> · 규칙 = `common_util_station_key`).
* · 측점 열쇠 칸 다섯 = `carryKeyedStates` — 넘김 = 카드 선택 넷 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이 ·
* 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점 고침은 버림(수만 셈).
* · 누가거리 열쇠 칸(`ROUTE_CARRY_STATES`) = `carryChainageStates` — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리 ·
* 구조물 조절 = 관 · 구조물 열쇠만.
* 쓰는 곳 = 브라우저 세션(`B06_Section_Station_Keys` · 끌기 동안 같은 노선 안 목록 갈림) · 서버 Node 입구
* `stations.carry`(노선 바뀐 [저장] · `design_calc_node_carry.ts`).
* ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS` + `STRUCTURE_ADJUST_KEYS` · 거울 시험 `test_417_route_carry.py`.
* ========================================================================== */
import {
chainageKey,
curveMoves,
dedupeStationKeys,
looseChainage,
stationKey,
type StationKeyRole,
} from "../../common_util/common_util_station_key";
import type { CrossSection } from "../B06_Section_Api_Fetch";
/** 측점 열쇠로 담는 세션 칸. */
export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope";
export const KEYED_STATES: readonly KeyedState[] = [
"crossdesign",
"shoulderext",
"bermdepth",
"rockb",
"cutslope",
];
/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */
const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const;
/** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */
const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope", "shoulderext", "bermdepth"];
/**
* 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸.
* ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS`(측점 열쇠마다) + `STRUCTURE_ADJUST_KEYS`(관 · 구조물 열쇠만) ·
* 거울 시험 `resources/tester/test_417_route_carry.py`.
*/
export const ROUTE_CARRY_STATES = {
revetx: ["revet_adjust"],
basinadjust: ["basin_adjust"],
inletstruct: ["inlet_structure"],
extrawall: ["extra_wall_counts"],
extraspan: ["extra_spans"],
revetlink: ["revet_link_detached", "revet_follow_grade"],
fordadjust: ["ford_adjust"],
boxadjust: ["box_adjust"],
crossw: ["display_half_width_m"],
"structure-notes": ["structure_note"],
"structure-note-cads": ["structure_note_cad"],
"detail-table": ["detail_table"],
shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"],
bermdepth: ["berm_depth_left_m", "berm_depth_right_m"],
uphill: [], // 상단측(측구 방향) — 프로젝트 칸 · 설계 칸 없음 · 노선 바뀐 [저장]은 안 보냄(요구 274)
} as const;
/** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */
const STRUCTURE_STATES = new Set<string>([
"revetx",
"basinadjust",
"inletstruct",
"extrawall",
"extraspan",
"revetlink",
"fordadjust",
"boxadjust",
]);
/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */
export const NEAR_M = 0.01;
export const HINT = "@at";
export type Keyed = { key: string; chainageM: number };
export type Raw = Record<string, unknown> & { [HINT]?: Record<string, number> };
/** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */
export type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole };
/** 관 측점의 관 id — 관 장부(세션 `pipe-sN`) · 찾기만(`near`) · 없으면 지음(`at`). */
export interface PipeIdBook {
near: (chainageM: number) => string | null;
at: (chainageM: number) => string;
}
const looksLikePipe = (section: CrossSection): boolean =>
Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? "");
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이
* 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */
export function stationKeysFrom(
sections: ReadonlyArray<CrossSection>,
intervalM: number | null,
known: ReadonlyArray<Owner>,
pipes: PipeIdBook | null,
): Keyed[] {
const planned = known.some((owner) => owner.kind === "pipe");
const keys = sections.map((section, index) => {
const at = section.chainage_m;
if (index === 0) return stationKey("bp", at, intervalM);
if (index === sections.length - 1) return stationKey("ep", at, intervalM);
if (section.kind !== "irregular") return stationKey(section.kind, at, intervalM);
const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M);
if (owner) return stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role);
if (looksLikePipe(section)) {
// 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E)
const id = pipes ? (planned ? pipes.near(at) : pipes.at(at)) : null;
return id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM);
}
return section.grid
? stationKey("regular", at, intervalM)
: stationKey("irregular", at, intervalM);
});
const chainages = sections.map((section) => section.chainage_m);
return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i] }));
}
/** 목록에서 그 자리(0.01 m 안) 가장 가까운 측점. */
export function nearestKeyed(list: ReadonlyArray<Keyed>, chainageM: number): Keyed | undefined {
let best: Keyed | undefined;
for (const entry of list) {
const gap = Math.abs(entry.chainageM - chainageM);
if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry;
}
return best;
}
/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */
export function keyIn(list: ReadonlyArray<Keyed> | undefined, chainageM: number): string {
return (list && nearestKeyed(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`;
}
/**
* 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금
* 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖).
*/
export function chainageIn(
list: ReadonlyArray<Keyed> | undefined,
key: string,
hint?: number,
): number | null {
const hit = list?.find((entry) => entry.key === key);
if (hit) return Number(chainageKey(hit.chainageM));
const at = looseChainage(key) ?? hint;
if (at === undefined) return null;
if (!list) return Number(chainageKey(at));
const near = nearestKeyed(list, at);
return near ? Number(chainageKey(near.chainageM)) : null;
}
/** 두 목록 사이 MC 가 움직인 곡선 열쇠(432-3 · 서버 `carry_cross_inputs` 와 같음). */
export function curveMovesIn(
from: ReadonlyArray<Keyed> | undefined,
to: ReadonlyArray<Keyed> | undefined,
): Map<string, string> {
const pairs = (list: ReadonlyArray<Keyed> | undefined) =>
(list ?? []).map((e) => [e.key, e.chainageM] as const);
return curveMoves(pairs(from), pairs(to));
}
/** 칸 원본 → [열쇠, 값, 쓴 때 자리]. */
export function rawEntries<V>(raw: Raw): Array<[string, V, number | undefined]> {
const hints = raw[HINT] ?? {};
return Object.entries(raw)
.filter(([key]) => key !== HINT)
.map(([key, value]) => [key, value as V, hints[key]]);
}
/** 칸 원본을 목록 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌. */
export function readKeyedIn<V>(raw: Raw, list: ReadonlyArray<Keyed> | undefined): Map<string, V> {
const out = new Map<string, V>();
for (const [key, value, hint] of rawEntries<V>(raw)) {
const at = chainageIn(list, key, hint);
if (at !== null) out.set(chainageKey(at), value);
}
return out;
}
/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */
function carriedValue(name: KeyedState, value: unknown): unknown {
if (CARRY_WHOLE.includes(name)) return value;
if (name !== "crossdesign" || !value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
const picked = Object.fromEntries(
CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]),
);
return Object.keys(picked).length ? picked : null;
}
/**
* 측점 열쇠 칸 다섯을 옛 목록 → 새 목록으로(넘김 규칙 = 머리 주석) — 칸마다 새 원본(빈 칸도) · 노선 밖으로 나간
* 고침의 열쇠(측점 단위).
*/
export function carryKeyedStates(
read: (name: KeyedState) => Raw,
from: ReadonlyArray<Keyed> | undefined,
to: ReadonlyArray<Keyed> | undefined,
): { states: Record<KeyedState, Raw>; outside: Set<string> } {
const outside = new Set<string>();
const curves = curveMovesIn(from, to);
const states = {} as Record<KeyedState, Raw>;
for (const name of KEYED_STATES) {
const next: Raw = { [HINT]: {} };
for (const [key, value] of rawEntries<unknown>(read(name))) {
// 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로
const fromAt = looseChainage(key) === null ? null : chainageIn(from, key);
const was = fromAt === null ? key : keyIn(from, fromAt);
const stable = curves.get(was) ?? was;
const toAt = chainageIn(to, stable, looseChainage(stable) ?? undefined);
const carried = carriedValue(name, value);
if (toAt === null || stable.startsWith("at:")) {
if (carried !== null) outside.add(stable);
continue;
}
if (carried === null) continue;
const toKey = keyIn(to, toAt);
next[toKey] = carried;
next[HINT]![toKey] = toAt;
}
states[name] = next;
}
return { states, outside };
}
/** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */
export interface CarryFacilities {
/** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */
movedAt?: (chainageM: number) => number | null | undefined;
/** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */
keyAt?: (key: string) => number | null | undefined;
}
/** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */
const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/;
/**
* 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고 · 관 종류 바꿈 자리 `kindreset`)을 옛 목록 → 새 목록
* 측점으로 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 · 못 옮긴 고침은 버림.
* 돌려줌 = 원본이 있던 칸만 새 원본 · 버린 고침의 열쇠(측점 단위).
* 시설 풀이(`facilities`) = 옛 저장 시설 측점 → 새 노선 그 시설 측점(좌표 짝 · 초안으로 안 따라간 고침) · 관 ·
* 구조물 열쇠 → 새 누가거리(관 id 규칙이 갈림 — 세션 `pipe-s3` · 계산 줄 `pipe:p8017`).
*/
export function carryChainageStates(
read: (name: string) => Record<string, unknown> | null,
from: ReadonlyArray<Keyed> | undefined,
to: ReadonlyArray<Keyed> | undefined,
facilities: CarryFacilities = {},
): { states: Record<string, Record<string, unknown>>; outside: Set<string> } {
const facilityAt = facilities.keyAt ?? (() => null);
const outside = new Set<string>();
const curves = curveMovesIn(from, to);
const moveAt = (state: string, text: string): string | null => {
const moved = facilities.movedAt?.(Number(text));
if (moved !== null && moved !== undefined) return chainageKey(moved);
const was = keyIn(from, Number(text));
const stable = curves.get(was) ?? was;
const structural = stable.startsWith("pipe:") || stable.startsWith("structure:");
// 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점)
const toAt =
stable.startsWith("at:") || (STRUCTURE_STATES.has(state) && !structural)
? null
: chainageIn(to, stable, (structural ? facilityAt(stable) : null) ?? Number(text));
if (toAt === null) outside.add(stable);
return toAt === null ? null : chainageKey(toAt);
};
const rekey = (state: string, cells: Record<string, unknown>): Record<string, unknown> => {
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cells)) {
const head = CHAINAGE_HEAD.exec(key);
const to = head && moveAt(state, head[2]);
if (head && to !== null) next[`${head[1]}${to}${head[3] ?? ""}`] = value;
}
return next;
};
const states: Record<string, Record<string, unknown>> = {};
for (const state of Object.keys(ROUTE_CARRY_STATES)) {
if ((KEYED_STATES as readonly string[]).includes(state)) continue; // 측점 열쇠 칸 = carryKeyedStates
const raw = read(state);
if (!raw || typeof raw !== "object") continue;
// 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹
states[state] =
state === "revetlink"
? Object.fromEntries(
Object.entries(raw).map(([part, cells]) => [
part,
cells && typeof cells === "object"
? rekey(state, cells as Record<string, unknown>)
: cells,
]),
)
: rekey(state, raw);
}
// 관 종류 바꿈 자리(318-4 · 프로젝트 칸) — 구조물 조절과 같은 규칙으로 옮김(445 M7)
const resets = read("kindreset");
if (resets) states.kindreset = rekey("revetx", resets);
return { states, outside };
}
+2
View File
@@ -29,6 +29,8 @@ NODE_IDS = frozenset(
"facility.follow",
"cross.design",
"cross.berm_spans",
"facility.rebase",
"stations.carry",
}
)
+7
View File
@@ -0,0 +1,7 @@
/* =============================================================================
* common_util/common_util_pipe_match.ts
* 관 · 구조물 찾는 거리 한 값 — 화면(`design_selection` 이 내줌) · 서버 Node 묶음(노선 바뀐 [저장] 넘김)이 같이 씀.
* ========================================================================== */
/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51;
+22 -5
View File
@@ -3,7 +3,8 @@
차례마다 안쪽 창구가 저마다 커넥션 · 파일을 쓰므로 DB 트랜잭션 하나로 못 꿴다. 대신 시작 전에
- DB 행 — `projects` 한 줄 · `project_workflow_stages` · `routes` 와 자식 넷(`route_points` ·
`route_statistics` · `longitudinal_sections` · `cross_sections`)
- storage — 저장 차례가 쓰는 폴더(`STORAGE_SCOPE` · 관 차례가 있으면 배수 폴더도)
- 노선 바뀐 저장(6-B)이면 지표면 모델 확정 상태(`surface_models.status` · 설계 지형 바꿈)
- storage — 저장 차례가 쓰는 폴더(`STORAGE_SCOPE` · 관 차례 · 노선 저장이면 배수 폴더도)
를 떠 두고, 하나라도 실패하면 둘 다 되돌린다. 되돌림 쓰기는 바뀐 것만(시놀로지 동기 — 안 바뀐
파일 · 표는 안 씀). 같은 프로젝트 저장이 겹치면 뒤 요청이 기다림(`project_lock`).
"""
@@ -122,8 +123,11 @@ async def _select(cursor: Any, sql: str, args: tuple | list) -> list[dict[str, A
return [dict(row) for row in await cursor.fetchall()]
async def snapshot_rows(connection: aiomysql.Connection, project_id: str) -> dict[str, list]:
"""프로젝트 한 줄 · 단계 · 노선과 자식 넷의 행(값 그대로)."""
async def snapshot_rows(
connection: aiomysql.Connection, project_id: str, surface: bool = False
) -> dict[str, list]:
"""프로젝트 한 줄 · 단계 · 노선과 자식 넷의 행(값 그대로) · `surface` = 지표면 모델 확정
상태도."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
rows = {
"projects": await _select(cursor, "SELECT * FROM projects WHERE id=%s", (project_id,)),
@@ -142,6 +146,12 @@ async def snapshot_rows(connection: aiomysql.Connection, project_id: str) -> dic
rows[table] = await _select(
cursor, f"SELECT * FROM {table} WHERE route_id IN ({marks}) ORDER BY id", ids
)
if surface:
rows["surface_models"] = await _select(
cursor,
"SELECT id, status FROM surface_models WHERE project_id=%s ORDER BY id",
(project_id,),
)
return rows
@@ -161,12 +171,13 @@ async def restore_rows(
connection: aiomysql.Connection, project_id: str, saved: dict[str, list]
) -> list[str]:
"""바뀐 표만 떠 둔 행으로 — 한 트랜잭션 → 되돌린 표 이름."""
now = await snapshot_rows(connection, project_id)
now = await snapshot_rows(connection, project_id, surface="surface_models" in saved)
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):
redo_surface = now.get("surface_models") != saved.get("surface_models")
if not (redo_tree or redo_stages or redo_project or redo_surface):
return []
await connection.begin()
try:
@@ -188,6 +199,11 @@ async def restore_rows(
f"UPDATE projects SET {sets} WHERE id=%s",
[row[name] for name in columns] + [project_id],
)
if redo_surface: # 확정 하나 = 다른 모델 COMPLETE(`confirm_surface_model` 짝) · 상태만
await cursor.executemany(
"UPDATE surface_models SET status=%s WHERE id=%s AND project_id=%s",
[(row["status"], row["id"], project_id) for row in saved["surface_models"]],
)
await connection.commit()
except Exception:
await connection.rollback()
@@ -196,4 +212,5 @@ async def restore_rows(
*(tree if redo_tree else ()),
*(("project_workflow_stages",) if redo_stages else ()),
*(("projects",) if redo_project else ()),
*(("surface_models",) if redo_surface else ()),
]
@@ -16,10 +16,10 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 조작 | 처음 놓이는 곳 | 브라우저 즉시 셈(모듈) | 서버 뒤 셈(API) | [저장]·[확정] 때 | 사본 | 위치(파일:줄) |
|---|---|---|---|---|---|---|
| 교점 끌기 | 편집기 메모리(planned · curveRadius · curveArc) → 놓을 때 세션 `route-draft` · 첫 초안이면 초안 전 상세를 hold | 묶음 전 단계(노선 초안 차례 · 새 표본) | 지반 대체 `/route/elevations` · 배수 `/drainage/live` · 유토 배분 `/haul-plan` | `/route/replan` — 노선 · 측점 · 표본 · 횡단 서버가 처음부터 · 뒤 차례 = 새 노선 | 11 | `B05_Profile_UI_RouteEdit.ts:126-145` · `B05_Profile_UI_RouteEdit_Pointer.ts:387-430` · `design_pipeline_page.ts:636-659` · `B05_Profile_UI_RouteEdit_Apply.ts:98-135` → `B05_Profile_Router_Replan.py:552-605` → `B05_Profile_Route_Save.py:337-510` |
| 교점 끌기 | 편집기 메모리(planned · curveRadius · curveArc) → 놓을 때 세션 `route-draft` · 첫 초안이면 초안 전 상세를 hold | 묶음 전 단계(노선 초안 차례 · 새 표본) | 지반 대체 `/route/elevations` · 배수 `/drainage/live` · 유토 배분 `/haul-plan` | `/design/save` `route` 칸 — 서버가 노선 · 측점 · 표본 · 횡단 처음부터 · 관 · 구조물 다시 얹기 · 세션 횡단 고침 넘김(Node) · 뒤 차례 = 새 노선 · 전부 아니면 없음 | 11 | `B05_Profile_UI_RouteEdit.ts:126-145` · `B05_Profile_UI_RouteEdit_Pointer.ts:387-430` · `design_pipeline_page.ts:636-659` · `B05_Profile_UI_RouteEdit_Apply.ts:94-135` → `B06_Section_Router_DesignSave.py:128-145` → `B05_Profile_Router_Replan.py:552-605` → `B05_Profile_Route_Save.py:337-510` |
| 방향키(0.1 m · Shift 1 m) | 교점 끌기와 같음 | 같음 | 같음 | 같음 | 11 | `B05_Profile_UI_RouteEdit_Keys.ts:61-64 · 118-159` |
| 교점 추가 · 삭제 | 편집기 메모리 → `route-draft` | 묶음 전 단계 · 측점 수 바뀜 → 갈아끼움 | 같음 | 같음 | 11 | `B05_Profile_UI_RouteEdit_Input.ts:149-158` · `logic/B05_Profile_UI_RouteEdit_Edits.ts:410-426` |
| 곡선 R · L | 편집기 `curveRadius` · `curveArc` → `route-draft` | 묶음 전 단계 · 확폭 · MC 이동 → 곡선 열쇠 옮김(`curveMoves`) | 같음 | `/route/replan` — 넣은 R · L 로 서버가 같은 규칙 | 11 | `B05_Profile_UI_RouteEdit_CurveBar.ts:76-162` · `logic/B05_Profile_Sections_Stations.ts:332-337` · `B05_Profile_Router_Replan.py:582 · 599-600` |
| 곡선 R · L | 편집기 `curveRadius` · `curveArc` → `route-draft` | 묶음 전 단계 · 확폭 · MC 이동 → 곡선 열쇠 옮김(`curveMoves`) | 같음 | `/design/save` `route` 칸 — 넣은 R · L 로 서버가 같은 규칙 | 11 | `B05_Profile_UI_RouteEdit_CurveBar.ts:76-162` · `logic/B05_Profile_Sections_Stations.ts:332-337` · `B05_Profile_Router_Replan.py:582 · 599-600` |
| 스냅 켬 · 간격 | 간격 = pref `routeedit-snap-step` · 켬 = `route-draft.stationSnap` | 켬 = 곡선 IP 를 옮김(한 걸음) → 묶음 전 단계 | — | 곡선표 파일 · 서버는 측점만 맞춤(IP 맞춤은 브라우저만) | 3 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` · `logic/design_pipeline_stations.ts:52-59` · `B05_Profile_Engine_Sections_Core.py:457-464` |
| 추천 노선 [적용] | result `routeedit-initial` → `route-draft` | 묶음 한 바퀴 | 미리 셈 `/route/initial` → `route_recommend/*` | **저장 밖** — 바로 `/route/replan`(as_initial) · 초기값 다시 뜸 · 초안 전부 지움 · 페이지 다시 세움 | 11 | `B05_Profile_UI_RouteEdit_Initial.ts:139 · 161-181` · `B05_Profile_UI_RouteEdit_Apply.ts:211 · 220-245` · `B05_Profile_Router_Replan.py:659-662` |
@@ -31,6 +31,6 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
|---|---|---|---|---|---|---|
| B06 폼 관 측점 · 옵션 고침 | `pipeFacilities` → 세션 `pipes` · 옮긴 자리(정수 m)로 옵션 · 캐시 얹기 · 집계 찾음 | B05 관 옮김과 같음 | 배수유역 다시 나눔 | 관 차례 `PUT /drainage/pipe-points` | 14 | `B06_Section_UI_Page_Structures_Panel.ts:300-345 · 536-560` |
| B06 구조물 패널 | 보기 `structures` → 시설 줄 `set` → 세션 `structures` | B05 구조물과 같음 | — | `PUT /route/structures` | 11 | `B06_Section_UI_Page_Structures_Panel.ts:211-227 · 582-598` |
| B06 평면 패널 끌기 | B05 교점 끌기와 같음(같은 편집기를 옮겨 붙임) | 같음 · B06 쪽 갈아끼움이 먼저 | 같음 | `/route/replan` | 11 | `design_plan_peek.ts:61-77` · `B05_Profile_UI_RouteEdit.ts:290-293 · 599-601` · `B06_Section_UI_Page_Station_Swap.ts:35` |
| B06 평면 패널 끌기 | B05 교점 끌기와 같음(같은 편집기를 옮겨 붙임) | 같음 · B06 쪽 갈아끼움이 먼저 | 같음 | `/design/save` `route` 칸 | 11 | `design_plan_peek.ts:61-77` · `B05_Profile_UI_RouteEdit.ts:290-293 · 599-601` · `B06_Section_UI_Page_Station_Swap.ts:35` |
| 넓힘(표본 폭 넘음) | 없음 — 확인창 뒤 바로 서버 | 응답 뒤 상세 강제 다시 받음 | `/sections/{노선}/regenerate` | **저장 밖** — DB 바로 씀(이미 정본) | 3 | `B06_Section_UI_Page_Common.ts:32-63` · `B06_Section_Router.py:378-388` · `B06_Section_Router_Widen.py:80` |
| [측점 만들기] | 없음 — 바로 서버 | 응답 뒤 상세 비움 · 강제 다시 받음 · 알림 | `/section/missing-stations` | **저장 밖** — 종단 · 횡단 파일 바로 씀 | 3 | `B06_Section_UI_Missing_Stations.ts:86-138` · `B06_Section_Router_Stations.py:236-296 · 365-407` |
@@ -19,26 +19,25 @@ 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` = 전 측점 강제 다시 셈(서버가 면적 버림) · 화면 잠금 | 노선 안 바뀜 = 조각 한 본문 `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` |
| [저장] | — | 앞 단계 `prepare` = 전 측점 강제 다시 셈(서버가 면적 버림) · 화면 잠금 | 조각 한 본문 `POST /design/save`(서버가 아래 차례 · 전부 아니면 없음 · 노선 바뀜도) · 서버 정본 다시 셈 | 끝 = `invalidateSectionDetail` · 두 보기가 새 상세 한 객체 받음 · 노선 바뀜 = 페이지 다시 세움 | — | `design_shared.ts:395-448` · `design_save.ts:183-237` |
| [확정] | — | [저장]과 같음 | [저장] + 빈 측점 기본 설계 · 단계 완료 · 측구 쪽을 B05 종단 파일로 되씀 | 끝 = `invalidateSectionDetail` → B07 로 넘어감 · 노선이 바뀐 [확정] = [저장]으로 · 다시 [확정] 알림 | — | `design_shared.ts:426-432` · `design_save.ts:224-228` · `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`)
## [저장] 차례(서버 `B06_Section_Router_DesignSave.py:99-195` · 칸 = `design_save_api.ts` SAVE_PART_KEYS)
- 노선 안 바뀐 [저장] · [확정](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 전).
- [저장] · [확정] = 한 본문 한 요청(472-6) — 브라우저가 차례마다 조각(본문 · 저장 뒤 정리)을 모아 `POST /design/save`(`design_save.ts:183-237`) · 서버가 아래 차례로 안쪽 창구 함수를 부름 · 하나라도 실패 = DB 행 · storage 되돌림(`common_util_save_guard.py`) · 차례 이름째 알림 · 다 저장한 뒤에만 조각 정리 · 같은 프로젝트 저장 겹침 = 뒤 요청 기다림.
- 노선 바뀐 [저장](6-B) = 같은 본문에 `route`(노선 초안) · `carry`(옛 노선 세션 횡단 고침 원본 `routeCarryInputs` `B06_Section_Station_Keys.ts:314`) · 기본 조각만(화면 몫 건너뜀) · 늘 [저장](노선이 바뀐 [확정] = 노선만 저장 · 다시 [확정] 알림) · 되돌림 범위 + 배수 폴더 · 지표면 확정.
| 차례 | 기본 창구 | 서버 | 노선 바뀐 [저장] | 위치(파일:줄) |
| 차례 | 브라우저 조각 | 서버 | 노선 바뀐 [저장] | 위치(파일:줄) |
|---|---|---|---|---|
| route | `pendingRouteSave` → `saveRouteDraft` | `/route/replan` · 관 · 구조물 따라감 · 노선 밖 알림 | 새 노선 id · 관 · 구조물 초안 다시 얹음 | `design_save.ts:255-274` |
| settings | `flushPageSettings` | `PUT /route/page-settings` | 그대로 | `design_save.ts:177-178` |
| pipes | `flushPendingPipes` + 화면 몫(배수판 다시 받음 · PUT 없음) | `PUT /drainage/pipe-points` | 기본 창구만 | `design_save.ts:179-180 · 289-294` |
| structures | `flushPendingStructures` | `PUT /route/structures` | 기본 창구만 | `design_save.ts:181-182` |
| plusStations | `flushPlusStations` | `PUT /section/plus-stations` | 기본 창구만 | `design_save.ts:183-184` |
| alignment | `flushAlignmentDraft` | `PUT /route/profile-alignment` | 초안 노선 0 편집을 새 노선에 | `design_save.ts:185-186` |
| culvertOptions | `flushCulvertOptions`(`culvertopt` · 옛 노선 열쇠) | `pipe_points.json` 한 번 더 PUT | 옛 노선 열쇠로 읽음 | `design_save.ts:187-188` · `B06_Section_Api_Culvert_Options.ts:67-95` |
| uphill | `flushRouteParts` | 상단측 | 건너뜀 — 횡단 차례가 옮긴 뒤 보냄 | `design_save.ts:189-190 · 285` · `B05_Profile_Api_Fetch.ts:321-342` |
| cross | `ctx.cross(confirm)` = 횡단 패치 한 요청 | `/sections/{노선}/save` · `confirm` · 재계산 맨 앞 종단 경고 다시 씀(`B06_Section_Server_Calc_Prebuild.py` refresh_alignment) | `carryAfterRouteSave`(새 측점으로 옮김) | `design_save.ts:275-283` |
| route | `pendingRouteSave` → 본문 `route` | `replan_route`(`/route/replan` 체인 · 관 · 구조물 · 추가 측점 좌표 따라감 · 종횡단 · 계획고 이월) | 맨 앞 · 새 노선 id · 관 · 구조물 초안 다시 얹기 = Node `facility.rebase`(`rebasePipeDraft` · `rebaseStructureDraft`) · 구조물 판번호 = 저장 뒤 판 | `B06_Section_Router_DesignSave.py:128-145` · `B06_Section_Router_DesignSave_Route.py` |
| settings | `pageSettingsPart` | `save_page_settings` | 그대로 | `design_save.ts:137` |
| pipes | `pipesPart` + 화면 몫(배수판 다시 받음 · PUT 없음) | `put_pipe_points`(`confirm` = 배수유역 다시 셈) | 기본 조각만 · 다시 얹은 목록 | `design_save.ts:139` |
| structures | `structuresPart` | `write_structures` | 기본 조각만 · 다시 얹은 목록 | `design_save.ts:141` |
| plusStations | `plusStationsPart` | `put_plus_stations` | 기본 조각만 | `design_save.ts:143` |
| alignment | `alignmentPart` | `save_profile_alignment` | 노선 초안 칸(0) ?? 옛 노선 칸 → 서버가 새 노선 번호로 · 저장 뒤 두 칸 비움 | `design_save.ts:115-126` |
| culvertOptions | `culvertOptionsPart`(`culvertopt` · 노선 열쇠) | 저장 관 위에 얹어 `pipe_points.json` 다시 씀 | 옛 노선 열쇠(누가거리)로 읽음 | `B06_Section_Api_Culvert_Options.ts:102-106` |
| uphill | `routePartsBody` | `confirm_latest_route`(상단측 · 비정규 측점 횡단) | 안 보냄 · 서버도 안 받음 · 저장 뒤 세션 비우고 수 알림(요구 274) | `design_save.ts:149-155` |
| cross | `ctx.crossPart(confirm)` = 횡단 patch | `save_sections` · `confirm_sections` · 재계산 맨 앞 종단 경고 다시 씀 | 옛 노선 세션 칸 → 새 측점 patch = Node `stations.carry`(`routeCarryPatches` `B06_Section_Route_Carry_Patch.ts:368` · 넘김 규칙 `logic/B06_Section_Route_Carry.ts`) · 고침이 없어도 한 번 · 표준 횡단면 · 풍선 = `routeCarryPart` | `B06_Section_Router_DesignSave.py:172-195` · `B05_Profile_UI_RouteEdit_Apply.ts:157` |
- 차례마다 따로 커밋 — 실패 = 그 차례에서 멈춤 · 이름째 알림 · 앞 차례는 저장된 채(`design_save.ts:297-299`).
- 저장 중 = `saving` 표시 · 묶음 맞춤은 저장 뒤로 미룸(`design_pipeline_page.ts:553`).
- 저장 중 = `saving` 표시 · 묶음 맞춤은 저장 뒤로 미룸(`design_pipeline_page.ts` saving 분기).
+1 -2
View File
@@ -51,9 +51,8 @@ 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` · `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` | 추정 |
| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠(누가거리 · 관이 따라 옮긴 새 자리와 안 맞으면 안 얹힘) | `design_save.ts:145` · `B06_Section_Router_DesignSave.py` `_patched_points` | 추정 · 옛 길과 같음 |
| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정 메모리 맵 · B05 상단측 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore | 관 · 구조물 = `test_472_5_shop_owner` · 나머지 추정 |
| 스냅 켬 · 간격 = 되돌리기 밖 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` | 추정 |
| `flushDesignStore` 부름 없음 — 멈춘 뒤 1 s 안 새로고침 = 가게 세션 사본 잃음(다시 셈) | `design_store.ts:179` | 코드 확인 |
@@ -51,6 +51,7 @@ function loadTs(file, stubs = {}) {
const surface = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Surface.ts"));
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": { readState: () => null, writeState: () => {} },
});
@@ -27,6 +27,7 @@ function loadTs(file, stubs = {}) {
// 세션(structure-pick) — 메모리 한 벌
const store = new Map();
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -27,6 +27,7 @@ global.requestAnimationFrame = (fn) => fn();
const store = new Map();
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -59,6 +59,7 @@ function fire(type, clientX, clientY) {
/* ── 진짜 모듈 ── */
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -22,6 +22,7 @@ function loadTs(file, stubs = {}) {
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -62,6 +62,7 @@ globalThis.window = { addEventListener() {}, removeEventListener() {} };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -39,6 +39,7 @@ const press = (key, { inInput = false, prevented = false } = {}) => {
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -26,6 +26,7 @@ globalThis.document = { addEventListener() {}, removeEventListener() {} };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -24,6 +24,7 @@ function loadTs(file, stubs = {}) {
}
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": { readState: () => null, writeState() {} },
});
const PREFIX = "irregular:";
@@ -488,8 +488,10 @@ const out = {};
const load = makeLoader(
[
B06("B06_Section_Api_Culvert_Options"),
B06("logic/B06_Section_Design_Keys"), // 칸 목록 한 벌(472-6)
path.join(ROOT, "A00_Common", "b_page_state.ts"),
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
path.join(ROOT, "common_util", "common_util_pipe_match.ts"), // 472-6
],
{
"./B06_Section_Section_Drafts": {
@@ -596,14 +598,22 @@ const loadDrafts = makeLoader(
const loadStore = makeLoader(
[
B06("B06_Section_Section_Store"),
B06("B06_Section_Route_Carry_Patch"), // patch 바탕 한 벌(472-6)
B06("B06_Section_Api_Culvert_Options"),
path.join(ROOT, "A00_Common", "b_page_state.ts"),
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
path.join(ROOT, "common_util", "common_util_pipe_match.ts"), // 472-6
],
{
"./B06_Section_Cross_Refresh": {
USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"],
},
"./logic/B06_Section_Design_Keys": {
USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"],
STRUCTURE_ADJUST_KEYS: makeLoader([B06("logic/B06_Section_Design_Keys")])(
B06("logic/B06_Section_Design_Keys"),
).STRUCTURE_ADJUST_KEYS,
},
},
);
@@ -89,8 +89,10 @@ const part1 = (async () => {
B05("B05_Profile_UI_Structures_Panel_Events"),
B05("B05_Profile_Api_Structures"),
B06("B06_Section_Api_Culvert_Options"),
B06("logic/B06_Section_Design_Keys"), // 칸 목록 · 찾는 거리 한 벌(472-6)
path.join(ROOT, "A00_Common", "b_page_state.ts"),
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
path.join(ROOT, "common_util", "common_util_pipe_match.ts"),
path.join(ROOT, "common_util", "common_util_summary_item.ts"),
],
{
@@ -78,6 +78,13 @@ function loadTs(file) {
}
const api = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts"));
// 472-6 — B06 [저장] = 한 본문 구조물 조각(옛 기본 창구 `flushPendingStructures` 걷음) · 서버 창구 = 가짜 PUT
async function saveLikeOneBody(projectId) {
const part = await api.structuresPart(projectId);
if (!part) return;
await api.saveStructures(projectId, part.body.base_revision, part.body.structures);
await part.after?.(undefined);
}
(async () => {
const P = "p1";
@@ -87,13 +94,13 @@ const api = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts
const notice = api.staleDefinitionNotice(P, opened.definition_version);
const staleOnOpen = api.definitionStale(P);
// 고침 없음 [저장](B06 기본 창구) — 정본을 그대로 다시 써 판 도장
await api.flushPendingStructures(P);
await saveLikeOneBody(P);
const afterSave = { version: server.definition_version, puts: [...puts] };
// 다시 열기 — 알림 없음 · 또 [저장] 해도 안 나감
const reopened = await api.fetchStructures(P);
const again = api.staleDefinitionNotice("p1-새탭", reopened.definition_version);
api.staleDefinitionNotice(P, reopened.definition_version);
await api.flushPendingStructures(P);
await saveLikeOneBody(P);
process.stdout.write(
JSON.stringify({
notice,
+19 -22
View File
@@ -36,34 +36,23 @@ const STUBS = {
currentRoute: () => "b05-profile",
remountRoute: () => calls.push("remount"),
},
"../A00_Common/design/design_pipeline_page": {
commitRouteFacilities: () => calls.push("commit"), // 저장 = 초안 전 관 · 구조물 버림(6-B)
},
"../B06_Section/B06_Section_Section_Store": {
invalidateSectionDetail: (_p, routeId) => calls.push(`invalidate:${routeId ?? "all"}`),
loadSectionDetail: async (_p, routeId) => (
calls.push(`load:${routeId}`),
{ tag: routeId, cross_sections: [] }
),
saveCachedCrossPatches: async (_p, routeId) => (calls.push(`crossSave:${routeId}`), 1),
},
// 6-B — 넘김은 서버(Node · 같은 TS) · 여기서는 옛 노선 세션 칸 원본만 냄
"../B06_Section/B06_Section_Station_Keys": {
useStationKeys: (_p, routeId, detail) => calls.push(`keys:${routeId}:${detail.tag}`),
carryStationDrafts: (_p, from, to, opts) => (
calls.push(`carry:${from}->${to}${opts?.quiet ? ":quiet" : ""}`),
2
),
// 417 — 누가거리 열쇠 조절 옮김
carryChainageStates: (_p, from, to) => (calls.push(`carryAt:${from}->${to}`), 1),
routeCarryInputs: (_p, from) => (calls.push(`carryInputs:${from}`), { from_keys: [], from }),
},
"../B06_Section/B06_Section_Section_Adjusts": {
withDraftAdjusts: (_d, _p, routeId) => calls.push(`adjusts:${routeId}`),
withCarriedEdits: (_d, _p, routeId) => calls.push(`edits:${routeId}`),
"../B06_Section/B06_Section_Api_Fetch": {
sectionConfirmBody: (standard, patches, massHaul) => ({ standard, patches, massHaul }),
},
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
fetchDetailPipePoints: async () => (calls.push("pipes"), { pipe_points: [] }),
},
"../A00_Common/design/design_pipe_ids": { pipeChainageOf: () => null },
"../B06_Section/B06_Section_UI_Standard_Panel": { readStandardCrossSession: () => ({ std: 1 }) },
"@util/common_util_mass_haul_balance_view": { balloonOffsetsPayload: () => ({ b: [1, 2] }) },
"./B05_Profile_Api_Fetch": {
clearRouteLatestCache: () => calls.push("latest:clear"),
flushUphillOverrides: async () => calls.push("uphill"), // 옮긴 뒤 · 횡단 패치 앞(443-2)
},
"./B05_Profile_Api_Replan": {
replanRoute: async (_p, body) => {
@@ -153,9 +142,17 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap
out.keptAfterSave = kept;
out.summary = apply.routeSaveSummary(saved, 2);
// 저장 뒤 횡단 초안 넘김 차례 — 새 상세 → 측점 열쇠 → 넘김 → 넘긴 고침 저장
// 노선 바뀐 [저장] 횡단 조각(6-B) — 옛 노선 세션 칸 원본 + 표준 횡단 · 풍선 · 저장 뒤 상단측 · 종류 바꿈 비움
calls.length = 0;
out.outside = await apply.carryAfterRouteSave("p1", 7, 9);
writeState("uphill", { "40.00": "right" }, "p1");
writeState("kindreset", { "45.00": "culvert" }, "p1");
const part = apply.routeCarryPart("p1", 7);
out.carry = part.carry;
out.crossBody = part.cross.body;
out.uphillBeforeAfter = readState("uphill", "p1");
await part.cross.after(undefined);
out.uphillAfter = readState("uphill", "p1");
out.kindresetAfter = readState("kindreset", "p1");
out.carryCalls = [...calls];
// 추천 [적용] — as_initial · 잠금 막 · 초안 비움 · 다시 세움
@@ -15,7 +15,7 @@ function loadTs(file, real) {
const box = { exports: {} };
const localRequire = (request) =>
real.some((name) => request.endsWith(name))
? loadTs(path.join(path.dirname(file), `${request}.ts`), [])
? loadTs(path.join(path.dirname(file), `${request}.ts`), ["common_util_pipe_match"]) // 472-6
: stub;
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
@@ -25,6 +25,7 @@ globalThis.document = { querySelector: () => null };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
+2 -19
View File
@@ -1,5 +1,5 @@
/* PLAN 429 단위검증 헬퍼 — [저장] 길 정합.
* ④ 노선 저장 뒤 횡단 저장 = 고침이 없어도 한 번(`saveCachedCrossPatches`)
* ④ 노선 저장 뒤 횡단 저장 = 고침이 없어도 한 번(472-6 6-B — 서버 한 본문 · 시험은 글 단언)
* ⑨ 추가 측점 = 계산 묶음 입력(`plusStations` · stations 부터) · 가게 초안 바뀜 → 묶음에 넣음(`feedFacilities`)
* TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구 · 화면 모듈은 가짜. 짝 = `test_429_save_path.py`. */
const fs = require("fs");
@@ -39,24 +39,7 @@ function loader(stubs, real = () => false) {
async function run() {
const out = {};
// ④ 고침이 없어도 횡단 저장 한 번 — 서버가 지금 종단으로 횡단을 다시 셈
const sent = [];
const store = loader({
"./B06_Section_Api_Fetch": {
saveSections: async (...args) => (sent.push(args.map((a) => a ?? null)), {}),
fetchSectionDetail: async () => null,
},
// 445 H5 — 노선 저장 뒤 본문 = 횡단 보기와 같은 칸(세션 고침 · 표준 횡단면 · 풍선)
"./B06_Section_Cross_Design_Session": { crossDesignChoices: () => new Map() },
"./B06_Section_UI_Page_Persist": {
readRockBoundarySession: () => ({}),
readCutSlopeSession: () => ({}),
},
"./logic/B06_Section_UI_Page_Patches": { mergeCrossPatches: (a, b) => [...a, ...b] },
"@util/common_util_mass_haul_balance_view": { balloonOffsetsPayload: () => ({}) },
})(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts"));
out.noPatchReturn = await store.saveCachedCrossPatches("p1", 9);
out.noPatchSent = sent;
// ④ 노선 저장 뒤 횡단 저장 한 번 = 서버 한 본문 몫(472-6 6-B · test_429_save_path 글 단언)
// ⑨ 묶음 — 추가 측점이 바뀌면 stations 부터 · 같은 값이면 안 셈
const DESIGN = path.join(ROOT, "A00_Common", "design");
+31 -16
View File
@@ -1,6 +1,6 @@
/* PLAN 448(442-2) 단위검증 헬퍼 — 페이지 설정 저장 차례 · 초안 내보내기 · 기준표 거울 재료.
* ① DESIGN_SAVE_ORDER 에 settings(route 뒤 · pipes 앞) · 노선 바꾼 [저장]에도 settings 기본 창구가 돎
* ② flushPageSettings — 초안 없음 = 안 보냄 · 성공 = 최신 응답 캐시에 얹고 초안 비움 · 실패 = 초안 그대로
* ① 노선 바꾼 [저장](472-6 6-B 한 본문)에도 settings 기본 조각이 실림(화면 몫 아님)
* ② pageSettingsPart — 초안 없음 = 안 보냄 · 성공 = 최신 응답 캐시에 얹고 초안 비움 · 실패 = 초안 그대로
* ③ 끝 측점 계획고 편집(시 · 종점 조정) — 칸 값 = 첫 · 끝 측점 station_offsets
* ④ TS 기준표(PROFILE_CRITERIA · SPEED_CHOICES) — 거울 시험이 파이썬과 대조
* TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구는 가짜. 짝 = `test_442_2_page_settings.py`. */
@@ -47,24 +47,36 @@ async function run() {
// ── ① 저장 차례 ──
const calls = [];
const KEYS = ["settings", "pipes", "structures", "plusStations", "alignment"];
const save = load(path.join(ROOT, "A00_Common", "design", "design_save.ts"), {
"../../B05_Profile/B05_Profile_Api_PageSettings": {
flushPageSettings: async (projectId) => calls.push(`settings:${projectId}`),
pageSettingsPart: (projectId) => ({
body: { projectId },
after: () => calls.push(`settings:${projectId}`),
}),
},
"./design_save_api": {
SAVE_PART_KEYS: [...KEYS, "cross"],
DesignSaveError: Error,
postDesignSave: async (_p, _routeId, _confirm, parts, extra = {}) => {
const sent = KEYS.filter((key) => parts[key]?.body);
calls.push(`post:${extra.route ? "route:" : ""}${sent.join(",")}`);
return { waited: false, steps: { route: { route_id: 9 } } };
},
},
"../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": {
pendingRouteSave: async () => ({ draft: 1 }),
saveRouteDraft: async () => (calls.push("route"), { route_id: 9 }),
carryAfterRouteSave: async () => [],
routeCarryPart: () => ({ carry: {}, cross: { body: {} } }),
routeSaved: () => undefined,
routeSaveSummary: () => "",
rebuildAfterRouteSave: async () => undefined,
},
});
out.order = [...save.DESIGN_SAVE_ORDER];
const done = await save.saveDesign({
projectId: "p1",
routeId: () => 3,
steps: { settings: async () => calls.push("own-settings") }, // 노선 바뀌면 화면 몫 건너뜀
cross: async () => calls.push("cross"),
parts: { settings: () => (calls.push("own-settings"), null) }, // 노선 바뀌면 화면 몫 건너뜀
crossPart: async () => (calls.push("cross"), null),
failed: (error) => calls.push(`failed:${error.message}`),
});
out.routeSaveDone = done;
@@ -96,16 +108,19 @@ async function run() {
clearState: (name, p) => session.delete(`${name}:${p}`),
},
"./B05_Profile_Api_Fetch": {
savePageSettings: async (projectId, body) => {
puts.push(body);
if (mode === "boom") throw new Error("서버 500");
return { warnings: [], profile_updated: true };
},
readRouteLatestCache: () => latest,
writeRouteLatestCache: (_p, value) => (latest = value),
},
});
await api.flushPageSettings("p1");
// 472-6 — 서버 한 창구가 settings 칸을 받고(가짜) 다 저장한 뒤에만 조각 정리(옛 창구 `flushPageSettings` 걷음)
const flushPageSettings = async (projectId) => {
const part = api.pageSettingsPart(projectId);
if (!part) return;
puts.push(part.body);
if (mode === "boom") throw new Error("서버 500");
await part.after?.({ warnings: [], profile_updated: true });
};
await flushPageSettings("p1");
out.putsWithoutDraft = puts.length;
const draft = api.pageSettingsOf({
designSpeed: 30,
@@ -120,13 +135,13 @@ async function run() {
api.writePageSettings("p1", draft);
out.restoreFromDraft = api.panelValuesOfDraft(api.readPageSettings("p1"));
mode = "boom";
out.boom = await api.flushPageSettings("p1").then(
out.boom = await flushPageSettings("p1").then(
() => "ok",
(error) => error.message,
);
out.draftAfterBoom = api.readPageSettings("p1");
mode = "ok";
await api.flushPageSettings("p1");
await flushPageSettings("p1");
out.draftAfterOk = api.readPageSettings("p1");
out.latestAfterOk = latest;
out.puts = puts.length;
+6 -9
View File
@@ -86,17 +86,14 @@ async function run() {
},
"@config/config_frontend": { API_BASE_URL: "", API_TIMEOUT_MS: 1000 },
});
// 472-6 — 상단측 차례 = 한 본문 `uphill` 칸(본문은 세션 · 비우기는 서버가 다 저장한 뒤 조각 정리 ·
// 하위 차례 실패 = 서버가 그 차례에서 되돌림 · 옛 창구 `flushUphillOverrides` · `saveRouteParts` 걷음)
state.set("uphill:p1", { "40.000": "right" });
reply = { failed_parts: ["상단측 병합"] };
out.flushFailed = await settle(api.flushUphillOverrides("p1"));
out.uphillAfterFail = state.get("uphill:p1") ?? null;
reply = { failed_parts: [] };
out.flushOk = await settle(api.flushUphillOverrides("p1"));
out.uphillAfterOk = state.get("uphill:p1") ?? null;
out.sentAgain = await settle(api.flushUphillOverrides("p1")); // 비었으니 안 보냄
out.exported = ["flushUphillOverrides", "saveRouteParts", "routePartsBody"].filter(
(name) => typeof api[name] === "function",
);
out.uphillAfterBody = state.get("uphill:p1") ?? null;
out.bodies = bodies;
reply = { failed_parts: ["비정규 측점 횡단"] };
out.partsFailed = await settle(api.saveRouteParts("p1", { irregular_stations: [] }));
return out;
}
@@ -73,6 +73,10 @@ const load = makeLoader(
B06("B06_Section_UI_Cross_Culvert_Types"),
B06("logic/B06_Section_Station_Swap"),
B06("logic/B06_Section_UI_Page_Patches"),
B06("logic/B06_Section_Design_Keys"), // 칸 목록 · 얹기 · patch 꼴 한 벌(472-6)
B06("logic/B06_Section_Route_Carry"),
B06("B06_Section_Route_Carry_Patch"),
path.join(ROOT, "common_util", "common_util_pipe_match.ts"),
path.join(ROOT, "A00_Common", "b_page_state.ts"),
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
],
+19 -13
View File
@@ -75,7 +75,7 @@ STUBS["B05_Profile_Api_Structures"] = {
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459) 안 돎
};
STUBS["B05_Profile_Api_PlusStations"] = {
flushPlusStations: async () => undefined,
outsideNotice: () => "",
plusStationsPart: () => ({ body: null }),
};
STUBS["B05_Profile_Api_Fetch"] = {
@@ -108,30 +108,36 @@ STUBS["design_save_api"] = {
"uphill",
"cross",
],
DesignSaveError: class extends Error {},
postDesignSave: async (_projectId, routeId, confirm, parts) => {
DesignSaveError: class extends Error {
constructor(message, step, restored) {
super(message);
this.step = step;
this.restored = restored;
}
},
postDesignSave: async (_projectId, routeId, confirm, parts, extra = {}) => {
const pipes = parts.pipes?.body;
calls.push([
"SAVE",
pipes === undefined || pipes === null ? "none" : (pipes.points ?? null),
confirm,
...(extra.route ? ["ROUTE"] : []),
]);
const align = parts.alignment?.body;
if (align) {
const { route_id: _id, ...draft } = align;
calls.push(["ALIGN", routeId, draft]);
}
return { waited: false, steps: {} };
// 472-6 6-B — 서버가 그 차례에서 실패 = 노선까지 되돌림(전부 아니면 없음)
const Err = STUBS["design_save_api"].DesignSaveError;
if (failAt) throw new Err(`${failAt} 409`, failAt, true);
return { waited: false, steps: extra.route ? { route: { route_id: 8 } } : {} };
},
};
STUBS["B05_Profile_UI_RouteEdit_Apply"] = {
pendingRouteSave: async () => routeBody,
saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }),
carryAfterRouteSave: async (_projectId, from, to, options) => {
calls.push(["CARRY", from, to, options?.save ?? true]);
failing("carry");
return 0;
},
routeCarryPart: (_projectId, from) => ({ carry: { from }, cross: { body: {} } }),
routeSaved: () => void calls.push(["ROUTE_SAVED"]),
routeSaveSummary: () => "",
rebuildAfterRouteSave: async () => void calls.push(["REBUILD"]),
};
@@ -180,10 +186,10 @@ async function run() {
alignment.set(0, { pvi: { 120: 1.5 } });
out.routeFail = await once({ route: true, fail: "structures" });
out.alignAfterFail = Object.fromEntries(alignment);
out.nextSave = await once({ routeId: 8 });
// 노선 저장 뒤 횡단 넘김 자체가 실패 → 두 번 안 넘김
out.nextSave = await once({ route: true }); // 다음 [저장] = 같은 초안 그대로 다시
// 472-6 6-B — 횡단 넘김(서버 Node) 실패도 노선까지 되돌림 · 화면은 그대로
alignment.clear();
out.carryFail = await once({ route: true, fail: "carry" });
out.carryFail = await once({ route: true, fail: "cross" });
return out;
}
@@ -25,6 +25,9 @@ function loadTs(file, stubs = {}) {
// 세션(structure-pick) — 메모리 한 벌
const store = new Map();
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": loadTs(
path.join(ROOT, "common_util", "common_util_pipe_match.ts"),
), // 찾는 거리 정의(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -0,0 +1,195 @@
/* 472-6 6-B 헬퍼 — 노선 바뀐 [저장] 횡단 넘김: 브라우저 옛 길(세션 함수를 옛 `carryAfterRouteSave` →
* `saveCachedCrossPatches` 차례 그대로) 결과 · 새 길(서버 Node `stations.carry`) 입력을 JSON 한 벌로.
* node helper_472_6_route_carry.mjs <묶음.mjs>
* 시험(`test_472_6_route_save.py`)이 새 길 입력을 진짜 Node 입구로 돌려 두 결과를 견줌. */
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
const [bundlePath] = process.argv.slice(2);
const m = await import(pathToFileURL(bundlePath).href);
const P = "p4726";
const R = 7; // 옛 노선
const R2 = 9; // 새 노선(서버가 저장한 것)
const clone = (value) => JSON.parse(JSON.stringify(value));
const pipe = { structure: "파형강관 D800", culvert: {} };
const section = (chainage_m, kind, extra = {}) => ({
station_id: `s${chainage_m}`,
chainage_m,
kind,
label: String(chainage_m),
samples: [],
design: {},
...extra,
});
const detailOf = (sections) => ({
longitudinal: { stations: sections, profile_alignment: { policy: { station_interval_m: 20 } } },
cross_sections: sections,
});
/* 새 노선 서버 상세 — 50 m 뒤가 3 m 밀림(관 80 → 83) · MC 110 → 112 · 구조물 130 사라짐 · 끝 140 → 143 */
const fresh = () =>
detailOf([
section(0, "bp"),
section(20, "regular", { design: { ground_type: "rock" } }),
section(40, "regular"),
section(60, "regular", { design: { display_half_width_m: 5, ground_type: "soil" } }),
section(83, "irregular", pipe),
section(103, "regular"),
section(112, "curve"),
section(123, "regular"),
section(143, "ep"),
]);
/* 옛 노선 열쇠 목록(브라우저가 끌기 동안 세운 것) · 측점 주인(초안 자리) · 관 장부 */
m.useStationKeyList(P, R, [
{ key: "end:BP", chainageM: 0 },
{ key: "grid:1", chainageM: 20 },
{ key: "grid:2", chainageM: 40 },
{ key: "grid:3", chainageM: 60 },
{ key: "pipe:p80", chainageM: 80 },
{ key: "grid:5", chainageM: 100 },
{ key: "curve:110.00", chainageM: 110 },
{ key: "grid:6", chainageM: 120 },
{ key: "structure:s1", chainageM: 130 },
{ key: "end:EP", chainageM: 140 },
]);
m.useStationOwners(P, [{ chainage_m: 83, owners: ["p80"] }], [{ id: "p80" }]);
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 83 }] }, P);
const pipesBefore = [{ id: "p80", chainage_m: 80 }];
const pipesAfter = [
{ id: "p80", chainage_m: 83, options: { inlet_revet_height_m: 1.8, inlet_revet_form: "I" } },
];
/* 옛 노선 세션 고침 — 측점 열쇠 칸 다섯 · 누가거리 칸 · 프로젝트 칸(종류 바꿈 · 상단측) */
m.writeCrossDesignChoice(P, R, 20, {
ground_type: "soil",
section_mode: "left_cut",
ditch_type: "u_flume",
two_stage_slope: true,
});
m.writeCrossDesignChoice(P, R, 130, { ground_type: "soil", section_mode: "right_cut" }); // 밖
m.writeShoulderExtend(P, R, 40, "left", 0.5);
m.writeBermDepth(P, R, 130, "right", 1); // 밖
m.writeState("rockb", { "grid:5": 1.5, "@at": { "grid:5": 100 } }, P, R);
m.writeState("cutslope", { "curve:110.00": 0.8, "@at": { "curve:110.00": 110 } }, P, R);
const wall = { x: 0.3, d: null, h: 1.5, m: null };
m.writeState("revetx", { "80.00:outlet": wall, "40.00:inlet": wall }, P, R); // 40 = 일반 측점 → 버림
m.writeState("basinadjust", { "80.00": { depth_m: 1.2 } }, P, R);
m.writeState("crossw", { "60.00": 6 }, P, R);
m.writeState("structure-notes", { "130.00": "메모", "20.00": "앞 메모" }, P, R);
m.writeState("detail-table", { "80.00": { rows: 2 } }, P, R);
m.writeState("revetlink", { detached: { "80.00": true }, grade: {} }, P, R);
m.writeState("kindreset", { "60.00": "culvert" }, P); // 일반 측점 = 관 · 구조물 열쇠 아님 → 버림
m.writeState("uphill", { "60.00": "right" }, P);
const out = {};
/* 새 길 입력 — 브라우저가 한 본문 `carry` 칸에 싣는 것(넘기기 전 세션 그대로) */
out.carry = clone(m.routeCarryInputs(P, R));
out.detail = fresh();
out.pipesBefore = pipesBefore;
out.pipesAfter = pipesAfter;
/* 옛 길 — `loadSectionDetail`(관 옵션 얹기) → `carryAfterRouteSave` → `saveCachedCrossPatches` 차례 그대로 */
const detail = fresh();
m.layPipeOptions(detail, pipesAfter);
m.useStationKeys(P, R2, detail);
const newAt = (id) => pipesAfter.find((p) => id !== undefined && p.id === id)?.chainage_m ?? null;
const pipeAt = (key) => {
const id = key.startsWith("pipe:") ? key.slice(5) : null;
if (!id) return null;
return id.startsWith("pipe-") ? m.pipeChainageOf(id, P) : newAt(id);
};
const movedAt = (chainageM) =>
newAt(pipesBefore.find((p) => Math.abs(p.chainage_m - chainageM) < 0.01)?.id);
const outside =
m.carryStationDrafts(P, R, R2, { quiet: true }) +
m.carryChainageStates(P, R, R2, { keyAt: pipeAt, movedAt });
m.withDraftAdjusts(detail, P, R2);
m.withCarriedEdits(detail, P, R2);
const base = m.crossPatchesFromCache(
detail,
m.kindChangedChainages(P),
detail.cross_sections.map((s) => s.chainage_m),
);
const overlay = [];
m.crossDesignChoices(P, R2).forEach((choice, chainage) => {
const patch = {
chainage_m: chainage,
ground_type: choice.ground_type,
section_mode: choice.section_mode,
};
if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side;
if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type;
if (choice.paved !== undefined) patch.paved = choice.paved;
if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope;
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
overlay.push(patch);
});
// 옛 `readRockBoundarySession` · `readCutSlopeSession` = 측점 열쇠 칸을 새 목록 누가거리로(`readKeyed`)
const keyed = (name) => [...m.readKeyed(name, P, R2)];
for (const [chainage, offset] of keyed("rockb"))
if (typeof offset === "number")
overlay.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset });
for (const [chainage, ratio] of keyed("cutslope"))
overlay.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio });
out.oldPatches = JSON.parse(
JSON.stringify(
m.mergeCrossPatches(
base,
overlay.filter((p) => Number.isFinite(p.chainage_m)),
),
),
);
out.oldOutside = outside;
/* 관 · 구조물 초안 다시 얹기 — 브라우저 옛 길이 부르던 같은 함수(서버 Node `facility.rebase` 와 견줌) */
out.rebaseInput = {
pipes: {
draft: [
{ id: "p80", chainage_m: 80, x: 80, y: 0 }, // 따라가기만 함 → 서버 자리
{ id: "p20", chainage_m: 25, x: 25, y: 0 }, // 사용자가 옮김 → 그대로
],
before: [
{ id: "p80", chainage_m: 80, x: 80, y: 0 },
{ id: "p20", chainage_m: 20, x: 20, y: 0 },
],
after: [
{ id: "p80", chainage_m: 83, x: 80, y: 0 },
{ id: "p20", chainage_m: 20, x: 20, y: 0 },
{ id: "pnew", chainage_m: 120, x: 117, y: 2 }, // 서버가 새로 놓은 교차 관
],
},
structures: {
draft: [{ structure_id: "s1", chainage_m: 60, start_m: null, end_m: null, x: 60, y: 0 }],
before: [{ structure_id: "s1", chainage_m: 60, start_m: null, end_m: null, x: 60, y: 0 }],
after: [{ structure_id: "s1", chainage_m: 63, start_m: null, end_m: null, x: 60, y: 0 }],
},
};
const r = out.rebaseInput;
out.oldRebase = {
pipes: m.rebasePipeDraft(r.pipes.draft, r.pipes.before, r.pipes.after),
structures: m.rebaseStructureDraft(r.structures.draft, r.structures.before, r.structures.after),
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,29 @@
/* 472-6 6-B 시험 묶음 진입점 — 브라우저 옛 길(세션 함수 차례 · 옛 `carryAfterRouteSave` + `saveCachedCrossPatches`)과
* 새 길 입력(`routeCarryInputs`)을 한 벌로 묶어 Node 헬퍼(helper_472_6_route_carry.mjs)가 부름. */
export {
carryChainageStates,
carryStationDrafts,
readKeyed,
routeCarryInputs,
useStationKeyList,
useStationKeys,
useStationOwners,
} from "../../B06_Section/B06_Section_Station_Keys";
export {
crossDesignChoices,
writeBermDepth,
writeCrossDesignChoice,
writeShoulderExtend,
} from "../../B06_Section/B06_Section_Cross_Design_Session";
export {
layPipeOptions,
withCarriedEdits,
withDraftAdjusts,
} from "../../B06_Section/B06_Section_Section_Adjusts";
export { crossPatchesFromCache } from "../../B06_Section/B06_Section_Route_Carry_Patch";
export { mergeCrossPatches } from "../../B06_Section/logic/B06_Section_UI_Page_Patches";
export { kindChangedChainages } from "../../B06_Section/B06_Section_Api_Culvert_Options";
export { pipeChainageOf } from "../../A00_Common/design/design_pipe_ids";
export { readState, writeState } from "../../A00_Common/b_page_state";
export { rebasePipeDraft, rebaseStructureDraft } from "../../common_util/common_util_route_follow";
export { chainageKey } from "../../common_util/common_util_station_key";
+24 -33
View File
@@ -33,14 +33,14 @@ let failStructures = false;
// 노선 저장(346-3) — 초안 · 실패를 시험이 정함
let routeBody = null;
let failRoute = false;
// 6-B — 노선 저장 · 다시 얹기 · 넘김은 서버 한 본문 · 여기서는 옛 노선 세션 칸 원본 조각만
const routeStub = {
pendingRouteSave: async () => routeBody,
saveRouteDraft: async () => {
calls.push("route");
if (failRoute) throw new Error("노선 저장 실패");
return { route_id: 9, pipes: { kept: 3, crossing: 1, concave: 0 }, cross_dropped: 1 };
},
carryAfterRouteSave: async (_p, from, to) => (calls.push(`carry:${from}->${to}`), 2),
routeCarryPart: (_p, from) => ({
carry: { from },
cross: { body: { carried: from }, after: () => calls.push("cross:carried:after") },
}),
routeSaved: () => calls.push("routeSaved"),
routeSaveSummary: (saved, outside) => (calls.push(`summary:${saved.route_id}:${outside}`), ""),
rebuildAfterRouteSave: async () => calls.push("rebuild"),
};
@@ -64,11 +64,23 @@ const SAVE_PART_KEYS = [
const saveApiStub = {
SAVE_PART_KEYS,
DesignSaveError,
postDesignSave: async (_p, routeId, confirm, parts) => {
postDesignSave: async (_p, routeId, confirm, parts, extra = {}) => {
const sent = SAVE_PART_KEYS.filter((key) => parts[key] && parts[key].body != null);
calls.push(`post:${routeId}:${confirm}:${sent.join(",")}`);
const moved = extra.route ? `:route(carry ${extra.carry?.from})` : "";
calls.push(`post:${routeId}:${confirm}:${sent.join(",")}${moved}`);
if (extra.route && parts.alignment?.body) {
const body = parts.alignment.body; // 서버가 새 노선 번호로 고쳐 씀(test_472_6_route_save)
calls.push(`alignment:${body.route_id}:${JSON.stringify(body.station_offsets)}`);
}
if (failStructures) throw new DesignSaveError("structures 실패", "structures", true);
return { waited: false, steps: { alignment: { dropped_edit_chainages: [] } } };
if (extra.route && failRoute) throw new DesignSaveError("노선 저장 실패", "route", true);
const route = extra.route
? {
route: { route_id: 9, pipes: { kept: 3, crossing: 1, concave: 0 }, cross_dropped: 1 },
carry: { outside: 2 },
}
: {};
return { waited: false, steps: { alignment: { dropped_edit_chainages: [] }, ...route } };
},
};
const STUBS = {
@@ -79,41 +91,22 @@ const STUBS = {
hideLoadingOverlay: () => calls.push("overlay:off"),
},
"../../B05_Profile/B05_Profile_Api_Pipes_Draft": {
flushPendingPipes: () => record("pipes")(),
pipesPart: () => ({ body: { points: [] }, after: () => calls.push("pipes:after") }),
readPendingPipes: () => null, // 관 초안 없음 — 노선 저장 뒤 얹기(349-0u)는 안 돎
writePendingPipes: () => undefined,
},
"../../B04_PreProcess/B04_PreProcess_Api_Fetch": {
fetchDetailPipePoints: async () => ({ pipe_points: [] }),
},
"../../common_util/common_util_route_follow": {
rebasePipeDraft: (draft) => draft,
rebaseStructureDraft: (draft) => draft,
},
"../../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")(),
outsideNotice: (count) => `노선 밖 ${count}`,
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)}`),
routePartsBody: async () => ({ irregular_stations: [] }), // 상단측 차례 = 종단 보기 몫과 같은 본문(463)
},
"../../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}`),
@@ -121,7 +114,6 @@ const STUBS = {
},
// 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings)
"../../B05_Profile/B05_Profile_Api_PageSettings": {
flushPageSettings: async () => undefined,
pageSettingsPart: () => null,
},
// 472-6 한 본문 창구 — 보낸 칸만 적음 · 구조물 실패 = 서버가 그 차례에서 되돌림
@@ -149,7 +141,7 @@ function loadTs(file) {
const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
const edit = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts"));
const { saveDesign, DESIGN_SAVE_ORDER } = loadTs(
const { saveDesign } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_save.ts"),
);
@@ -222,7 +214,6 @@ async function run() {
out.saveOk = ok;
out.saveCalls = [...calls];
out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") };
out.order = DESIGN_SAVE_ORDER;
// ④ 구조물이 실패하면 그 차례에서 멈춤 · 차례 이름째 알림(429 ⑨) · 노선 없으면 안 나감
calls.length = 0;
+1 -1
View File
@@ -246,7 +246,7 @@ def test_손질_없으면_도면_표_그대로():
def test_살림_목록_짝에_detail_table():
assert "detail_table" in USER_TOUCHED_KEYS
text = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
text = (ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts").read_text(encoding="utf-8") # 목록 한 벌(472-6)
block = re.search(r"export const USER_TOUCHED_KEYS = \[(.*?)\] as const;", text, re.S)
assert block and '"detail_table"' in block.group(1)
+1 -1
View File
@@ -202,7 +202,7 @@ def test_patch_칸_왕복() -> None:
with pytest.raises(ValidationError):
CrossSectionPatch(chainage_m=40, berm_depth_left_m=-0.1) # 도로 위 금지
assert {"berm_depth_left_m", "berm_depth_right_m"} <= set(USER_TOUCHED_KEYS)
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
refresh = (ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts").read_text(encoding="utf-8") # 목록 한 벌(472-6)
assert '"berm_depth_left_m",' in refresh and '"berm_depth_right_m",' in refresh
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "patch.berm_depth_left_m = sides.left" in persist
+1 -1
View File
@@ -77,7 +77,7 @@ def test_브라우저가_CAD_칸을_저장_patch_에_싣는다():
state = read("A00_Common/b_page_state.ts")
assert '"structure-note-cads": { bucket: "draft", scope: "route" }' in state
# 캐시 설계 → B05 [저장] 길(`crossPatchesFromCache`)도 같은 목록
assert '"structure_note_cad",' in read("B06_Section/B06_Section_Cross_Refresh.ts")
assert '"structure_note_cad",' in read("B06_Section/logic/B06_Section_Design_Keys.ts") # 472-6
# 모달 = 빈 CAD(캔버스만) · 글 상자 없음
detail = read("B06_Section/B06_Section_UI_Cross_Detail.ts")
assert "textarea" not in detail and "mountNoteCad(" in detail and "noteCad?.destroy()" in detail
+3 -2
View File
@@ -80,8 +80,9 @@ 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")
# 472-6 — 종단 보기 몫은 기본 조각 본문 그대로(메모리만 더 비움) · 노선 바뀐 저장은 옛 창구
# 472-6 — 종단 보기 몫은 기본 조각 본문 그대로(메모리만 더 비움) · 노선 바뀐 저장도 한 본문(6-B ·
# 옛 창구 `flushRouteParts` 걷음 · 노선 바뀌면 손 상단측 안 보냄 = 요구 274)
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
assert "flushRouteParts" not in save and "if (moved) return null;" in save
+1 -1
View File
@@ -252,7 +252,7 @@ def test_wiring_sources() -> None:
def test_structure_keys_mirror() -> None:
"""서버 · 브라우저 열쇠 목록이 같음 · 모두 승계 목록 안."""
ts = _src("B06_Section", "B06_Section_Api_Culvert_Options.ts")
ts = _src("B06_Section", "logic", "B06_Section_Design_Keys.ts") # 목록 한 벌(472-6)
block = ts[ts.index("export const STRUCTURE_ADJUST_KEYS = [") :]
block = block[: block.index("] as const")]
assert re.findall(r'"([a-z_]+)"', block) == list(STRUCTURE_ADJUST_KEYS)
@@ -75,19 +75,17 @@ def test_저장_뒤_가게_초안_비움_결과_한_줄(out):
def test_초안_넘김_차례(out):
assert out["carryCalls"] == [
"invalidate:9",
"load:9",
"keys:9:9",
"pipes", # 관 열쇠 → 새 자리(서버가 옮긴 관)
"carry:7->9:quiet",
"carryAt:7->9", # 417 — 조절 · 측점 폭 · 상세 표도 새 노선 측점으로
"uphill", # 443-2 — 상단측도 새 측점으로 옮긴 뒤 · 횡단 패치 앞
"adjusts:9",
"edits:9", # 옮긴 고침을 새 상세 설계 칸에 → 같은 [저장] 횡단 패치
"crossSave:9",
]
assert out["outside"] == 3 # 측점 열쇠 2 + 누가거리 열쇠 1
"""472-6 6-B — 넘김(측점 열쇠 · 누가거리 칸 · 얹기 · patch)은 서버가 같은 TS 로(Node `stations.carry` ·
`test_472_6_route_save`) · 브라우저는 옛 노선 세션 칸 원본 + 횡단 본문만 · 저장 뒤 상단측 · 종류 바꿈 비움."""
assert out["carryCalls"][0] == "carryInputs:7"
assert out["carry"] == {"from_keys": [], "from": 7}
assert out["crossBody"] == {
"standard": {"std": 1},
"massHaul": {"balloon_offsets": {"b": [1, 2]}},
}
assert out["uphillBeforeAfter"] == {"40.00": "right"} # 다 저장하기 전엔 그대로
assert out["uphillAfter"] is None and out["kindresetAfter"] is None
assert any("상단측 손 고침 1곳" in c for c in out["carryCalls"]) # 요구 274 — 새 지형이 정함
def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
@@ -95,8 +93,8 @@ def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
body = out["recommendBody"]
assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"}
assert body["draft"]["nodes"] == [[1, 1], [9, 9]]
# 잠금 막 하나 · 저장 직전 저장 관 떠 둠(417 — 저장 뒤 같은 관 id 새 자리로 조절 옮김)
assert out["recommendCalls"] == ["overlay:on", "pipes", "replan", "overlay:off"]
# 잠금 막 하나(472-6 6-B — 저장 직전 저장 관 떠 두기는 서버 한 본문 몫 · 추천은 초안을 비우니 안 받음)
assert out["recommendCalls"] == ["overlay:on", "replan", "commit", "overlay:off"] # 초안 전 시설 버림
assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시
assert out["rebuilt"] == 1
@@ -114,9 +112,12 @@ def test_노선_반영_단추_없앰_배선():
apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
assert "addEventListener" not in apply # 단추 듣기 없음 — [저장]이 부름
save = _read("A00_Common/design/design_save.ts")
assert save.index('"route",') < save.index('"pipes",')
assert "await pendingRouteSave(ctx.projectId)" in save
assert "carryAfterRouteSave(ctx.projectId, fromRouteId, routeId)" in save
# 6-B — 노선 초안 = 한 본문 `route` 칸 · 서버가 맨 앞에서 노선 저장(옛 차례 길 걷음)
assert "route ? { route, carry: carried?.carry } : {}" in save
assert "carryAfterRouteSave" not in save and "saveRouteDraft" not in save
server = _read("B06_Section/B06_Section_Router_DesignSave.py")
assert server.index('step("route"') < server.index('step("settings"')
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
at = initial.index("onApply: (picked) => {")
assert "saveRecommendedRoute(" in initial[at : at + 900]
@@ -129,8 +130,11 @@ def test_넘김_알림은_결과_한_줄에_합침():
keys = _read("B06_Section/B06_Section_Station_Keys.ts")
assert "{ quiet = false }: { quiet?: boolean } = {}," in keys
assert "if (outside.size && !quiet)" in keys
apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
assert "carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true })" in apply
# 6-B — 서버 넘김이 버린 수(`outside` · 측점 열쇠 + 누가거리 칸)를 결과 한 줄에(`routeSaveSummary`)
patch = _read("B06_Section/B06_Section_Route_Carry_Patch.ts")
assert "outside: keyed.outside.size + chain.outside.size" in patch
save = _read("A00_Common/design/design_save.ts")
assert "routeSaveSummary(saved, carryOutside)" in save
def test_노선_반영_글자_없음():
+6 -2
View File
@@ -34,9 +34,13 @@ def test_노선이_바뀌면_좌표로_따라감(tmp_path: Path) -> None:
def test_저장_차례_계획고_앞() -> None:
save = (ROOT / "A00_Common/design/design_save.ts").read_text(encoding="utf-8")
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
# 472-6 — 차례 = 한 본문 칸 차례(브라우저) · 서버 한 창구 차례
api = (ROOT / "A00_Common/design/design_save_api.ts").read_text(encoding="utf-8")
order = api[api.index("SAVE_PART_KEYS = [") :]
assert order.index('"structures"') < order.index('"plusStations"') < order.index('"alignment"')
server = (ROOT / "B06_Section/B06_Section_Router_DesignSave.py").read_text(encoding="utf-8")
assert server.index('step("structures"') < server.index('step("plusStations"')
assert server.index('step("plusStations"') < server.index('step("alignment"')
def test_옛_측점_옮기기는_추가_측점을_구조물로_안_만듦() -> None:
# 저장된 추가 측점 = 이름 "" 비정규 측점 — 옛 자유 글 옮기기가 「기타」 구조물로 바꾸면 안 됨
+1 -1
View File
@@ -41,7 +41,7 @@ from B06_Section.B06_Section_Router_Design import ( # noqa: E402
)
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
TS_KEYS = ROOT / "B06_Section" / "B06_Section_Station_Keys.ts"
TS_KEYS = ROOT / "B06_Section" / "logic" / "B06_Section_Route_Carry.ts" # 넘김 규칙 한 벌(472-6)
@pytest.fixture(scope="module")
+11 -3
View File
@@ -48,9 +48,17 @@ def _ground() -> list[dict]:
def test_노선_저장_뒤_고침이_없어도_횡단_저장_한_번(out):
# 본문 없이 = 서버가 다시 셈만 · 풍선 자리는 늘 실음(445 H5 — 횡단 보기 [저장]과 같은 본문)
assert out["noPatchSent"] == [["p1", 9, None, None, {"balloon_offsets": {}}]]
assert out["noPatchReturn"] == 0
"""472-6 6-B — 노선 바뀐 [저장]은 서버가 횡단 차례를 늘 한 번(넘긴 patch 가 없어도 · 뒤에 나간 계획고로
다시 셈) · 브라우저 횡단 조각 = 풍선 자리 늘 실음(445 H5 — 횡단 보기 [저장]과 같은 본문)."""
server = (ROOT / "B06_Section" / "B06_Section_Router_DesignSave.py").read_text(encoding="utf-8")
moved = server[server.index("carry_cross(project_id, route_id") :]
moved = moved[: moved.index(" elif request.cross is not None:")]
assert 'body["cross_patches"] = carried.get("patches") or None' in moved
assert "crossing = save_sections(project_id, route_id" in moved
assert 'cross = await step("cross", crossing)' in server.replace("\n", " ").replace(" ", "")
apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
part = apply[apply.index("export function routeCarryPart") :]
assert "balloon_offsets: balloonOffsetsPayload()," in part
def test_서버_저장이_횡단_높이를_저장_종단에서(tmp_path):
+10 -3
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import asyncio
import copy
import json
import re
import subprocess
from pathlib import Path
from types import SimpleNamespace
@@ -338,13 +339,19 @@ def test_E3_뜰_params_없으면_안_씀():
def test_저장_차례_settings_는_노선_뒤_관_앞(out):
order = out["order"]
assert order.index("route") + 1 == order.index("settings") < order.index("alignment")
# 472-6 — 차례 = 서버 한 창구(노선 맨 앞 · 6-B)
server = (HERE.parents[1] / "B06_Section" / "B06_Section_Router_DesignSave.py").read_text(
"utf-8"
)
names = ("route", "settings", "pipes", "alignment")
at = [re.search(r'step\(\s*"' + name + '"', server).start() for name in names]
assert at == sorted(at)
def test_노선_바꾼_저장에도_settings_기본_창구(out):
assert out["routeSaveDone"] is True
assert out["routeSaveCalls"] == ["route", "settings:p1"] # 화면 몫(own) 아님 · 기본 창구
# 한 본문에 노선 · settings 기본 조각 · 화면 몫(own) · 화면 횡단 조각 아님 · 다 저장한 뒤 정리
assert out["routeSaveCalls"] == ["post:route:settings", "settings:p1"]
def test_초안_내보내기(out):
+12 -7
View File
@@ -2,7 +2,7 @@
- 옛 누가거리 열쇠 상단측 = 측점 열쇠(옮김 목록 `ROUTE_CARRY_STATES` · 415 · 417 규칙)로 새 노선 측점에.
- 새 노선에 같은 측점이 없으면 뺌 · 수를 돌려줌(「노선 밖 고침」 · 상단측 알림).
- 저장 한 줄기: 노선을 저장했으면 상단측 차례는 건너뛰고 `carryAfterRouteSave` 가 옮긴 뒤 · 횡단 패치 앞에 보냄.
- 저장 한 줄기(472-6 6-B · 요구 274): 노선이 바뀐 [저장]은 손 상단측을 안 보냄 · 새 지형이 정함(옛 443-2 갈음).
Node 헬퍼 `helper_443_2_uphill_carry.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_443_2_entry.ts`)을 부름.
./venv/Scripts/python.exe -m pytest resources/tester/test_443_2_uphill_carry.py -q
@@ -67,11 +67,16 @@ def test_세션_없으면_그대로(out):
def test_노선_저장이면_상단측_차례는_옮긴_뒤():
"""요구 274(사용자 확정 · 472-6 6-B) — 노선이 바뀐 [저장]은 손 상단측을 안 들고 감 · 새 지형이 정함.
옛 443-2(옮긴 뒤 보냄)를 갈음 — 상단측 차례 안 보냄 · 서버도 노선 바뀜이면 그 칸을 안 받음 · 저장 뒤 세션
상단측 비우고 수만 알림. 같은 노선 안의 측점 열쇠 옮김(아래 헬퍼 시험)은 그대로."""
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8")
assert 'if (step === "uphill" && routeSaved) continue;' in save
assert "if (moved) return null;" in save # 상단측 조각 없음
apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text("utf-8")
body = apply.split("export async function carryAfterRouteSave(")[1].split("\n}\n")[0]
carry = body.index("carryChainageStates(")
flush = body.index("await flushUphillOverrides(projectId)")
assert carry < flush < body.index("saveCachedCrossPatches(") # 옮긴 뒤 · 횡단 패치 앞
assert "상단측 ${uphillDropped}곳" in body # 못 맞춤 알림
body = apply.split("export function routeCarryPart(")[1].split("\n}\n")[0]
assert 'clearState("uphill", projectId);' in body and "상단측 손 고침 ${uphill}곳" in body
keys = (ROOT / "B06_Section" / "B06_Section_Station_Keys.ts").read_text("utf-8")
inputs = keys.split("export function routeCarryInputs(")[1].split("\n}\n")[0]
assert 'state === "uphill"' in inputs # 넘김 입력에서 뺌
server = (ROOT / "B06_Section" / "B06_Section_Router_DesignSave.py").read_text("utf-8")
assert "if request.uphill is not None and not moved:" in server
+14 -5
View File
@@ -60,14 +60,23 @@ def test_구조물_차례가_토스트로_삼키지_않음():
def test_상단측_실패면_세션_그대로_성공이면_비움(out):
assert out["flushFailed"] == "상단측 병합 실패"
assert out["uphillAfterFail"] == {"40.000": "right"}
assert out["flushOk"] == "ok" and out["uphillAfterOk"] is None
assert len(out["bodies"]) == 3 # 실패 · 성공 · (빈 세션은 안 보냄) · 확정 하위 하나
"""472-6 — 상단측 = 한 본문 `uphill` 칸 · 본문 = 세션 · 세션 비움 = 서버가 다 저장한 뒤 조각 정리만
(실패면 정리가 안 돎 = 세션 그대로 · 옛 창구 `flushUphillOverrides` 걷음)."""
assert out["exported"] == ["routePartsBody"] # 옛 창구 둘 걷음 · 본문 한 벌만
assert out["uphillAfterBody"] == {"40.000": "right"} and out["bodies"] == []
fetch = (ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts").read_text("utf-8")
assert "uphill_overrides: uphillOverridesOf(projectId)," in fetch # 본문 = 세션
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8")
part = save[save.index('case "uphill":') :]
assert 'after: () => clearState("uphill", projectId),' in part[:500]
assert "for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(" in save # 성공 뒤에만
def test_확정_하위_실패면_던짐(out):
assert out["partsFailed"] == "비정규 측점 횡단 실패"
"""472-6 — 하위 차례(비정규 측점 횡단 · 상단측 병합) 실패 = 서버 한 본문이 상단측 차례에서 실패 · 되돌림."""
server = (ROOT / "B06_Section" / "B06_Section_Router_DesignSave.py").read_text("utf-8")
assert 'if parts.get("failed_parts"):' in server
assert 'raise StepFailed("uphill", " · ".join(parts["failed_parts"]) + " 실패")' in server
def test_종단_보기_상단측_차례도_같은_창구():
+20 -10
View File
@@ -231,24 +231,34 @@ def test_M5_새_행은_버린_수에_안_셈():
def test_H5_노선_저장_뒤_본문_M7_kindreset_옮김():
store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8")
body = store[store.index("export async function saveCachedCrossPatches") :]
# 472-6 6-B — 노선 저장 뒤 본문 = 서버 Node 넘김(`routeCarryPatches` · 옛 `saveCachedCrossPatches` ·
# `sessionOverlay` 몸통 그대로) · 표준 횡단면 · 풍선 = 브라우저 횡단 조각(`routeCarryPart`)
patch = (ROOT / "B06_Section" / "B06_Section_Route_Carry_Patch.ts").read_text(encoding="utf-8")
body = patch[patch.index("export function routeCarryPatches") :]
for needle in (
"sessionOverlay(projectId, routeId)",
"readStandardCrossSession(projectId)",
"balloon_offsets: balloonOffsetsPayload()",
"crossPatchesFromCache(detail, kindChanged, savedAt)",
'read<CardChoice>("crossdesign")',
'read<unknown>("rockb")',
'read<number>("cutslope")',
"mergeCrossPatches(",
):
assert needle in body, needle
overlay = store[store.index("function sessionOverlay") :]
for needle in ("crossDesignChoices(", "readRockBoundarySession(", "readCutSlopeSession("):
assert needle in overlay, needle
apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
part = apply[apply.index("export function routeCarryPart") :]
for needle in ("readStandardCrossSession(projectId)", "balloon_offsets: balloonOffsetsPayload()"):
assert needle in part, needle
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert (
"cachedCrossPatches(projectId, routeId, edits.crossPatches)" in persist
) # 472-6 횡단 조각
# 넘김 규칙 = logic 한 벌(472-6 6-B) · 세션 겉이 kindreset 을 프로젝트 칸에 다시 씀
rules = (ROOT / "B06_Section" / "logic" / "B06_Section_Route_Carry.ts").read_text(
encoding="utf-8"
)
carry = rules[rules.index("export function carryChainageStates") :]
assert 'states.kindreset = rekey("revetx", resets)' in carry
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
assert 'if (name === "kindreset") writeState("kindreset", next, projectId);' in keys
idle = keys[keys.index("function moveIdleStates") :]
assert 'writeState("kindreset", move(resets), projectId)' in idle
+13 -9
View File
@@ -2,8 +2,8 @@
· 관 정본 PUT = 세션 관 초안 한 번(B06 고침 직후 [저장] = 초안 값) · 배수판 메모리는 안 보냄
· 초안 없음 = [저장] PUT 없음(판이 안 서도 관 수 그대로) · [확정] = 칸 없이(서버 저장분) · 빈 초안 = 다 지움
· 노선 저장 뒤 구조물 차례 실패 → 0 칸 계획고가 새 노선 칸에 · 횡단 초안 넘김(저장 없이) · 다시 세움 →
다음 [저장]이 새 노선으로 계획고를 보냄 · 넘김 자체가 실패하면 두 번 안 넘김
· 노선 바뀐 [저장] 중 구조물 · 횡단 넘김 차례 실패(472-6 6-B) → 서버가 노선까지 되돌림 · 초안 그대로(0 칸
계획고 그대로 · 다시 안 세움) → 다음 [저장]이 같은 초안을 그대로 다시 보냄(옛 433-12 「새 노선 칸으로 옮김」 갈음)
· 관 PUT 끝 측점 맞춤 실패 = 응답 `stations_failed`
짝: `helper_449_save_pipes.cjs`(실제 `design_save.ts` 를 Node 로 · 서버 창구만 가짜).
"""
@@ -69,19 +69,23 @@ def test_빈_초안은_다_지움(out):
def test_노선_저장_뒤_실패_초안_새_노선(out):
"""472-6 6-B — 한 본문 · 서버가 구조물 차례에서 실패하면 노선까지 되돌림 → 초안 그대로 · 다시 안 세움."""
case = out["routeFail"]
assert not case["ok"] and "구조물" in case["failure"]
assert case["calls"] == [["ROUTE"], ["CARRY", 7, 8, False], ["REBUILD"]]
assert out["alignAfterFail"] == {"8": {"pvi": {"120": 1.5}}} # 0 칸 비움 · 새 노선 칸
assert not case["ok"] and "구조물 차례에서 실패" in case["failure"]
assert case["calls"] == [["SAVE", "none", False, "ROUTE"], ["ALIGN", 7, {"pvi": {"120": 1.5}}]]
assert out["alignAfterFail"] == {"0": {"pvi": {"120": 1.5}}} # 노선 초안 칸 그대로
align = [call for call in out["nextSave"]["calls"] if call[0] == "ALIGN"]
assert align == [["ALIGN", 8, {"pvi": {"120": 1.5}}]]
assert align == [["ALIGN", 7, {"pvi": {"120": 1.5}}]] # 다음 [저장]이 그대로 다시
assert (
out["nextSave"]["calls"][-1] == ["REBUILD"] and ["ROUTE_SAVED"] in out["nextSave"]["calls"]
)
def test_넘김_실패면_두_번_안_넘김(out):
"""472-6 6-B — 넘김은 서버 한 본문 안(Node) · 실패 = 노선까지 되돌림 · 화면 넘김 · 다시 세움 없음."""
case = out["carryFail"]
assert not case["ok"]
assert [call for call in case["calls"] if call[0] == "CARRY"] == [["CARRY", 7, 8, True]]
assert case["calls"][-1] == ["REBUILD"]
assert not case["ok"] and "횡단 차례에서 실패" in case["failure"]
assert case["calls"] == [["SAVE", "none", False, "ROUTE"]]
def test_공용_저장이_판_메모리를_안_보냄():
+8 -3
View File
@@ -4,7 +4,7 @@
Node 입구 = `A00_Common/design/logic/design_calc_node.ts`(`build:design-calc` · id → 함수 표).
못박는 것
- id · # 하나씩 · 1.1 표 # 전부(R1 – N1 · 43).
- id · # 하나씩 · 1.1 표 # 전부(R1 – N1 · 43) + R9 · F10(472-6 6-B).
- id 마다 실행기 있음 — `node:` 는 입구 표에 · `py:` · `browser:` · ts · 짝 이름은 그 파일에 ·
거울 시험 파일 있음.
- 묶음 `STAGE_DEPS`(`design_pipeline.ts`) = 등록부 입력에서 만든 기댐(같은지만 · 묶음 안 고침).
@@ -46,6 +46,8 @@ NODE_IDS = {
"facility.follow",
"cross.design",
"cross.berm_spans",
"facility.rebase",
"stations.carry",
}
pytestmark = pytest.mark.skipif(
@@ -101,6 +103,8 @@ GEOMETRY_PULLS = (
"B06_Section/B06_Section_UI_Cross_Revetment.ts",
"B06_Section/B06_Section_UI_Cross_Wall.ts",
"B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts",
# 노선 바뀐 [저장] 넘김 patch(472-6 6-B) — 세션 칸 → 설계 칸 얹기가 관 벽 · 단 · 세월 · BOX 기본값(위 그림 파일)을 씀
"B06_Section/B06_Section_Route_Carry_Patch.ts",
)
KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS)
@@ -190,8 +194,9 @@ def test_id_하나씩_표_전부(loaded) -> None:
nos = [row["no"] for row in rows]
assert len(ids) == len(set(ids))
assert len(nos) == len(set(nos))
want = {f"{k}{i}" for k, n in (("R", 8), ("G", 4), ("P", 8), ("C", 7)) for i in range(1, n + 1)}
want |= {f"F{i}" for i in range(1, 10)} | {"E1", "E2"} | {f"D{i}" for i in range(1, 5)} | {"N1"}
want = {f"{k}{i}" for k, n in (("R", 9), ("G", 4), ("P", 8), ("C", 7)) for i in range(1, n + 1)}
# R9 · F10 = 노선 바뀐 [저장] 다시 얹기(472-6 6-B)
want |= {f"F{i}" for i in range(1, 11)} | {"E1", "E2"} | {f"D{i}" for i in range(1, 5)} | {"N1"}
assert set(nos) == want
+392
View File
@@ -0,0 +1,392 @@
"""PLAN 472-6 6-B — 노선 바뀐 [저장]도 한 본문 · 다시 얹기 서버 Node(같은 TS).
① 넘김 — 브라우저 옛 길(세션 함수를 옛 `carryAfterRouteSave` → `saveCachedCrossPatches`
차례로) 결과 == 서버 Node `stations.carry`(`routeCarryPatches`) 결과(patch · 버린 수) ·
손 상단측 안 넘김(요구 274).
② 다시 얹기 — 관 · 구조물 초안(`rebasePipeDraft` · `rebaseStructureDraft`) ==
Node `facility.rebase`.
③ 옛 코드 실패 — 한 본문이 `route` · `carry` 칸을 받음 · 브라우저가 노선 저장 창구를 안 부름.
④ 실제 창구(검증_sub8 · DB · storage) — 노선 차례 뒤 실패(시험 갈래 · 넘김 실패) = 노선 행 ·
자식 표 · storage 그대로 · 정상 한 본문 = 옛 차례(노선 저장 → 다시 얹기 → 횡단 저장)와
행 · 파일 같음.
검증 프로젝트가 없는 자리(다른 PC · DB 없음)는 ④ 건너뜀.
./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_route_save.py -q
"""
from __future__ import annotations
import asyncio
import json
import re
import shutil
import subprocess
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
from common_util.common_util_calc_runner import run_calc # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="프론트엔드 의존성 미설치",
)
def _node(*args: str, timeout: int = 300) -> str:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", *args], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=timeout
)
assert proc.returncode == 0, proc.stderr
return proc.stdout
@pytest.fixture(scope="module")
def out(tmp_path_factory) -> dict:
"""vite ssr 로 진짜 모듈을 묶어 옛 길을 돌림(417 꼴)."""
work = tmp_path_factory.mktemp("r6b")
_node(
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_472_6_route_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
)
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_472_6_route_entry.js", bundle)
return json.loads(_node(str(HERE / "helper_472_6_route_carry.mjs"), str(bundle)))
@pytest.fixture(scope="module")
def carried(out) -> dict:
inputs = {
**out["carry"],
"detail": out["detail"],
"pipes_before": out["pipesBefore"],
"pipes_after": out["pipesAfter"],
}
result = run_calc(["stations.carry"], {"carry": inputs})
assert result is not None
return result["stations.carry"]
def _by_chainage(patches: list[dict]) -> dict[str, dict]:
return {f"{patch['chainage_m']:.2f}": patch for patch in patches}
def test_넘김_옛_브라우저_길과_서버_Node_같음(out, carried) -> None:
assert carried["patches"] == out["oldPatches"]
assert carried["outside"] == out["oldOutside"]
def test_넘김_값_사용자_칸(out, carried) -> None:
"""같은 값이 뜻있는지 — 카드 선택 · 사면 쉬프트 · 암선 · 암 경사(MC 옮김) · 관 조절 · 메모가
새 측점에."""
by = _by_chainage(carried["patches"])
assert by["20.00"]["ground_type"] == "soil" and by["20.00"]["two_stage_slope"] is True
assert by["20.00"]["ditch_type"] == "u_flume"
assert "section_mode" not in by["20.00"] # 단면 유형 = 새 지형이 정함
assert by["20.00"]["structure_note"] == "앞 메모"
assert by["40.00"]["shoulder_extend_left_m"] == 0.5
assert "revet_adjust" not in by["40.00"] # 일반 측점 옹벽 조절 = 안 넘김(관 · 구조물 열쇠만)
assert by["60.00"]["display_half_width_m"] == 6 # 세션 폭이 저장분(5) 위에
assert by["103.00"]["rock_boundary_offset_m"] == 1.5
assert by["112.00"]["cut_slope_ratio_user"] == 0.8 # MC 110 → 112(곡선 열쇠 옮김)
pipe = by["83.00"]
assert pipe["revet_adjust"]["outlet"]["x"] == 0.3 # 관 따라 80 → 83
assert pipe["revet_adjust"]["inlet"]["h"] == 1.8 # 관 옵션 벽 높이(저장 관 · 새 자리)
assert pipe["basin_adjust"]["depth_m"] == 1.2 and pipe["detail_table"] == {"rows": 2}
assert pipe["revet_link_detached"] is True
assert not any(
p.get("structure_reset") for p in carried["patches"]
) # 일반 측점 종류 바꿈 = 버림
assert carried["outside"] == out["oldOutside"] >= 4 # 구조물 130 고침 셋 · 40 옹벽 · 60 종류
def test_손_상단측은_안_넘김_요구274(out) -> None:
assert "uphill" not in out["carry"]["states"]
assert out["carry"]["states"]["kindreset"] == {"60.00": "culvert"}
def test_다시_얹기_옛_함수와_서버_Node_같음(out) -> None:
result = run_calc(["facility.rebase"], {"rebase": out["rebaseInput"]})
assert result is not None
assert result["facility.rebase"] == out["oldRebase"]
pipes = {pipe["id"]: pipe["chainage_m"] for pipe in result["facility.rebase"]["pipes"]}
assert pipes == {"p80": 83, "p20": 25, "pnew": 120} # 따라감 · 사용자 옮김 그대로 · 새 교차 관
assert result["facility.rebase"]["structures"][0]["chainage_m"] == 63
def test_옛_코드_실패_한_본문이_노선을_받음() -> None:
from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest
request = DesignSaveRequest.model_validate(
{
"route_id": 7,
"route": {"draft": {"nodes": [[0, 0], [10, 0]]}},
"carry": {"states": {}, "from_keys": None, "owners": [], "pipe_ids": None},
}
)
assert request.route is not None and request.carry is not None
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8")
assert "saveRouteDraft" not in save and "carryAfterRouteSave" not in save
assert re.search(r"postDesignSave\([^;]*route \? \{ route, carry", save, re.S)
apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
assert "export async function carryAfterRouteSave" not in apply
# ── 실제 창구 ──────────────────────────────────────────────────────────────
PROJECT = "9872e9a7-e483-427b-a6b2-72cdbc063bc3" # 검증_sub8(OWNERS.md)
VOLATILE = ("created_at", "updated_at", "confirmed_at", "started_at", "completed_at")
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 / "B05_Profile" / "route").is_dir() else None
except Exception:
return None
ROOT_DIR = _db_ready()
live = pytest.mark.skipif(ROOT_DIR is None, reason="검증_sub8 · DB 없음")
def _rows() -> dict:
from config.config_db import close_db_pool, init_db_pool
async def run():
pool = await init_db_pool()
try:
async with pool.acquire() as connection:
return await guard.snapshot_rows(connection, PROJECT, surface=True)
finally:
await close_db_pool()
return asyncio.run(run())
def _state(root: Path) -> tuple[dict, dict]:
"""(행 — 시각 칸 뺌, 파일 내용 해시) — 노선 바뀐 저장 범위 전부."""
import hashlib
clean = {
table: [{k: str(v) for k, v in row.items() if k not in VOLATILE} for row in values]
for table, values in _rows().items()
}
files = {
relative: hashlib.sha1(path.read_bytes()).hexdigest()
for relative, path in guard._files(root, guard.STORAGE_SCOPE + guard.PIPE_SCOPE).items()
if not relative.startswith("_derived/")
}
return clean, files
def _plan_draft() -> dict:
"""지금 계획노선 노드 · 곡선 그대로 + 가운데 노드 하나를 3 m 옆으로(노선 끌기)."""
from B05_Profile.B05_Profile_Router_Replan import read_route_plan
from config.config_db import close_db_pool, init_db_pool
async def run():
await init_db_pool()
try:
return await read_route_plan(UUID(PROJECT))
finally:
await close_db_pool()
plan = asyncio.run(run())
nodes = plan["nodes"]
at = len(nodes) // 2
points = [[float(n["x"]), float(n["y"])] for n in nodes]
points[at][1] += 3.0
return {
"rev": 1,
"nodes": points,
"curveOn": [True] * len(nodes),
"curveRadius": [None] * len(nodes),
"stationSnap": bool(plan.get("station_snap")),
"stationSnapStep": plan.get("station_snap_step"),
}
def _save(body: dict):
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:
return await save_design(UUID(PROJECT), DesignSaveRequest.model_validate(body), None)
finally:
await close_db_pool()
response = asyncio.run(run())
return response.status_code, json.loads(bytes(response.body).decode("utf-8"))
def _latest_route_id() -> int:
return max(int(row["id"]) for row in _rows()["routes"])
def _body(draft: dict, **extra) -> dict:
return {
"route_id": _latest_route_id(),
"route": {"draft": draft},
"carry": {"states": {}, "from_keys": None, "owners": [], "pipe_ids": None},
"cross": {},
**extra,
}
@live
def test_노선_차례_뒤_실패면_노선_행_storage_그대로(monkeypatch) -> None:
before = _state(ROOT_DIR)
monkeypatch.setenv("AISLO_DESIGN_SAVE_FAIL_AFTER", "route")
status, body = _save(_body(_plan_draft()))
assert status == 409 and body["step"] == "route" and body["restored"] is True, body
assert _state(ROOT_DIR) == before # 새 노선 행 · 자식 표 · 노선 파일 · 관 · 구조물 그대로
@live
def test_넘김_실패면_노선까지_그대로(monkeypatch) -> None:
from B06_Section import B06_Section_Router_DesignSave_Route as moving
before = _state(ROOT_DIR)
def broken(ids, inputs):
if ids == ["stations.carry"]:
raise RuntimeError("넘김 일부러 실패")
return run_calc(ids, inputs)
monkeypatch.setattr(moving, "run_calc", broken)
status, body = _save(_body(_plan_draft()))
assert status == 409 and body["step"] == "cross" and body["restored"] is True, body
assert "넘김 일부러 실패" in body["message"]
assert _state(ROOT_DIR) == before
def _normalized(state: tuple[dict, dict], new_ids: set[int]) -> tuple[dict, dict]:
"""새 노선 번호 · 행 id · 계산한 때(`computed_at`)를 지운 비교 꼴 — 옛 길 · 새 길이 다른
자동 번호 · 시각을 받음."""
rows, files = state
numbers = "|".join(str(i) for i in sorted(new_ids, reverse=True)) or "x"
# 3D 파일 = 0 채운 번호(`corridor_0444.json`)
rename = re.compile(rf"(?<!\d)0*({numbers})(?!\d)")
keep = {}
for table, values in rows.items():
out = []
for row in values:
row = {k: v for k, v in row.items() if k not in ("id", "route_id", "computed_at")}
out.append(rename.sub("N", json.dumps(row, sort_keys=True, ensure_ascii=False)))
keep[table] = sorted(out)
return keep, {rename.sub("N", k): v for k, v in files.items()}
@live
def test_정상_한_본문은_옛_차례와_같음() -> None:
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
from B05_Profile.B05_Profile_Router_Replan import replan_route
from B06_Section import B06_Section_Router_DesignSave_Route as moving
from B06_Section.B06_Section_Router_Confirm import save_sections
from B06_Section.B06_Section_Schema import SectionConfirmRequest
from config.config_db import close_db_pool, get_db_pool, init_db_pool
start = _state(ROOT_DIR)
draft = _plan_draft()
old_ids = {int(row["id"]) for row in _rows()["routes"]}
async def old_way():
"""옛 브라우저 차례 — 노선 저장(`/route/replan`) → 횡단 넘김 patch → 횡단 저장."""
await init_db_pool()
try:
async with get_db_pool().acquire() as connection:
rows = await guard.snapshot_rows(connection, PROJECT, surface=True)
files = guard.snapshot_storage(ROOT_DIR, guard.STORAGE_SCOPE + guard.PIPE_SCOPE)
before = await moving.saved_facilities(UUID(PROJECT))
saved = await replan_route(
UUID(PROJECT), RouteSaveRequest.model_validate({"draft": draft})
)
carried = await moving.carry_cross(UUID(PROJECT), int(saved["route_id"]), None, before)
await save_sections(
UUID(PROJECT),
int(saved["route_id"]),
SectionConfirmRequest.model_validate({"cross_patches": carried["patches"] or None}),
)
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(_body(draft))
assert status == 200 and body["status"] == "success", body
assert set(body["steps"]) == {"route", "carry", "cross"}
new = _state(ROOT_DIR)
ids = {int(row["id"]) for row in _rows()["routes"]} | {
int(row["id"]) for row in json.loads(json.dumps(old[0]["routes"]))
}
ids -= old_ids
(rows_new, files_new), (rows_old, files_old) = _normalized(new, ids), _normalized(old, ids)
assert [t for t in rows_new if rows_new[t] != rows_old.get(t)] == []
assert (
sorted(
k
for k in files_new.keys() | files_old.keys()
if files_new.get(k) != files_old.get(k)
)
== []
)
finally:
asyncio.run(undo())
files.discard()
assert _state(ROOT_DIR) == start
def test_저장한_노선_초안은_버림으로_되돌리지_않음() -> None:
"""화면 흠(6-B 화면 검증) — 저장 뒤 노선 초안 칸을 비우면 묶음이 「초안 버림」으로 보고 초안 전 관 ·
구조물 초안을 세션에 되써 다음 [저장]이 옛 자리로 되돌렸음 → 저장 성공이면 초안 전 값을 버림(commit)."""
apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
body = apply[apply.index("export function routeSaved") :]
body = body[: body.index("\n}\n")]
assert body.index("commitRouteFacilities(projectId);") < body.index('set("routeDraft", null)')
page = (ROOT / "A00_Common" / "design" / "design_pipeline_page.ts").read_text(encoding="utf-8")
assert "commit: () => void (beforeDraft = null)," in page
assert "if (!beforeDraft) return;" in page # 버린 차례 = 초안 전 값이 있을 때만 되씀
@@ -54,7 +54,12 @@ def test_거리_정의는_한_곳뿐이고_옛_0_05는_없다():
text = _read(os.path.relpath(os.path.join(current, name), ROOT))
if re.search(r"\b(const|let)\s+PIPE_MATCH_M\b", text):
definitions.append(name)
assert definitions == ["design_selection.ts"]
assert (
definitions == []
) # 정의 = common_util 한 곳(서버 Node 묶음도 끔 · 472-6) · 페이지는 내줌만
station = _read("common_util/common_util_pipe_match.ts")
assert re.search(r"export const PIPE_MATCH_M = 0\.51;", station)
assert "export { PIPE_MATCH_M }" in _read("A00_Common/design/design_selection.ts")
for name, folder in (
("B05_Profile_UI_Structures_Panel.ts", "B05_Profile"),
("design_view_profile.ts", "A00_Common/design"),
@@ -90,7 +90,9 @@ def test_0_은_표준값으로_되돌림이다():
def test_다시_계산해도_사용자_값이_남는다():
"""표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 (사용자 원문 끝줄)."""
assert "cut_slope_ratio_user" in USER_TOUCHED_KEYS
assert '"cut_slope_ratio_user"' in _REFRESH.read_text(encoding="utf-8")
assert '"cut_slope_ratio_user"' in (_B06 / "logic" / "B06_Section_Design_Keys.ts").read_text(
encoding="utf-8"
) # 목록 한 벌(472-6)
def test_계산_전에_넘어간다():
+15 -20
View File
@@ -15,32 +15,28 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
#: (파일, 함수 이름, 비었을 때 돌아가는 표시)
#: (파일, 함수 이름, 비었을 때 돌아가는 표시) — 472-6 한 본문 조각(빈 초안 = null 조각 = 서버가 그 차례를
#: 건너뜀 · 옛 창구 함수는 6-B 에서 걷음). 상단측은 463 부터 늘 나감(비정규 측점 횡단 맞춤 · 노선 바뀜이면 안 나감).
CHANNELS = [
(
ROOT / "B05_Profile" / "B05_Profile_Api_Pipes_Draft.ts",
"flushPendingPipes",
"pipesPart",
"관 목록",
),
(
ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts",
"flushUphillOverrides",
"상단측(측구 방향)",
),
(
ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts",
"flushPendingStructures",
"structuresPart",
"구조물",
),
(
ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts",
"flushCulvertOptions",
"culvertOptionsPart",
"관 옵션",
),
]
#: 비었을 때 빠져나가는 모양 — `return;` 만 있는 이른 반환.
_EARLY_RETURN = re.compile(r"\breturn\s*;")
#: 비었을 때 빠져나가는 모양 — 빈 조각(`return null;`) 이른 반환.
_EARLY_RETURN = re.compile(r"\breturn\s+null\s*;")
def _body(path: Path, name: str) -> str:
@@ -69,21 +65,20 @@ def test_저장이_창구를_다섯_다_거친다():
B05 [임시저장] · B06 [저장] · [확정]이 모두 그것을 탄다."""
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8")
for token in (
"flushCulvertOptions(",
"flushRouteParts(", # 상단측 차례 = 종단 보기 몫과 같은 본문(463)
"flushPendingPipes(",
"flushPendingStructures(",
"culvertOptionsPart(",
"body: await routePartsBody(projectId),", # 상단측 차례 = 종단 보기 몫과 같은 본문(463)
"pipesPart(",
"structuresPart(",
):
assert token in save, f"{token} 가 저장 한 줄기에서 사라짐"
# 472-6 — 노선 안 바뀐 저장은 한 본문 조각(조정창 구간값 = 세션 `culvertopt`) · 노선 바뀐
# 저장은 위 창구 차례 그대로
# 472-6 — 노선이 바뀌든 안 바뀌든 한 본문 조각(조정창 구간값 = 세션 `culvertopt` · 옛 창구 차례 걷음)
assert 'culvertOptionsPart(stateKey("culvertopt", projectId, routeId))' in save, (
"B06 조정창 구간값이 한 본문 저장에서 빠짐"
)
# 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨) · 한 본문은 서버가 차례 이름째 되돌림
assert "throw stepFailed(step, error)" in save, "실패한 차례에서 멈춤이 빠짐"
# 차례가 실패하면 서버가 차례 이름째 되돌림(429 ⑨ · 전부 아니면 없음)
assert "STEP_LABEL[error.step]" in save, "한 본문 실패 차례 이름이 빠짐"
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
api = (ROOT / "A00_Common" / "design" / "design_save_api.ts").read_text(encoding="utf-8")
order = api[api.index("SAVE_PART_KEYS = [") :]
order = order[: order.index("]")]
names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
assert [order.index(f'"{n}"') for n in names] == sorted(order.index(f'"{n}"') for n in names), (
+2 -2
View File
@@ -38,8 +38,8 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None:
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")
# 472-6 — 노선 안 바뀐 저장 = 한 본문(`saveOneBody`) · 재계산 뒤에 횡단 조각을 모음
run = _body(save, "async function saveOneBody", 4000)
# 472-6 — [저장] = 한 본문(`saveDesign` · 노선 바뀜도) · 재계산 뒤에 횡단 조각을 모음
run = _body(save, "export async function saveDesign", 4000)
assert run.index("ctx.prepare?.()") < run.index("ctx.crossPart(confirm)"), (
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
)
@@ -11,7 +11,8 @@ from pathlib import Path
from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS
ROOT = Path(__file__).resolve().parents[2]
TS_PATH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts"
TS_PATH = ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts" # 목록 한 벌(472-6)
REFRESH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts"
# 브라우저 목록에만 있고 서버 목록에는 없는 둘 — 부르는 쪽이 따로 붙인다.
_CALLER_ADDED = {"status", "pavement_suggested"}
@@ -36,7 +37,7 @@ def test_서버와_브라우저_목록이_같다():
def test_브라우저_목록도_한_벌이다():
"""보존 목록·저장 payload 가 같은 한 벌을 쓰는지 — 손나열이 되살아나면 깨진다."""
text = TS_PATH.read_text(encoding="utf-8")
text = REFRESH.read_text(encoding="utf-8")
assert "...USER_TOUCHED_KEYS" in text, "PRESERVED_KEYS 가 목록을 다시 적고 있음"
store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8")
assert "USER_TOUCHED_KEYS" in store, "저장 payload 가 목록을 안 씀"
+38 -16
View File
@@ -10,6 +10,7 @@ Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린
import json
import os
import re
import subprocess
import pytest
@@ -48,8 +49,14 @@ def test_계획고_가게_한_벌(out):
def test_저장_차례(out):
assert out["order"] == [
"route",
# 472-6 — 차례 = 한 본문 칸(브라우저) · 서버 한 창구 · 노선(346-3 · 6-B)은 서버 맨 앞
api = open(
os.path.join(HERE, "..", "..", "A00_Common", "design", "design_save_api.ts"),
encoding="utf-8",
).read()
keys = api[api.index("SAVE_PART_KEYS = [") :]
keys = re.findall(r'"([A-Za-z]+)"', keys[: keys.index("]")])
assert keys == [
"settings", # 448 페이지 설정 — 노선 뒤
"pipes",
"structures",
@@ -58,7 +65,13 @@ def test_저장_차례(out):
"culvertOptions",
"uphill",
"cross",
] # 346-3 노선 먼저
]
server = open(
os.path.join(HERE, "..", "..", "B06_Section", "B06_Section_Router_DesignSave.py"),
encoding="utf-8",
).read()
at = [re.search(rf'step\(\s*"{key}"', server).start() for key in ["route", *keys]]
assert at == sorted(at)
assert out["saveOk"] is True
# 472-6 — 노선 초안 없으면 조각을 모아 한 요청(서버 한 창구 · 전부 아니면 없음) ·
# 저장 뒤 조각 정리는 차례대로 · 본문 없는 조각(추가 측점 초안 없음)도 정리는 돎
@@ -97,19 +110,21 @@ def test_초기화는_초안과_한_벌을_버림(out):
def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out):
"""346-3 — 노선 초안이 있으면 맨 앞 · 관 · 구조물 기본 창구 · 계획고 초안(옛 노선 7 칸)을 새 노선 9 로 ·
화면 몫 일(옛 노선 판)은 건너뜀 · 횡단 초안 넘김(측점 열쇠) · 결과 한 줄 · 다시 세움."""
"""346-3 · 472-6 6-B — 노선 초안이 있으면 같은 한 본문에 `route` · `carry`(옛 노선 7 세션 칸) · 관 · 구조물
기본 조각 · 계획고 초안(옛 노선 7 칸 · 서버가 새 노선 9 로) · 상단측 안 보냄(요구 274) · 화면 몫 일(옛 노선 판)은
건너뜀 · 횡단 = 넘김 조각 · 다 저장한 뒤 정리 · 결과 한 줄 · 다시 세움."""
assert out["routeOk"] is True
assert out["routeCalls"] == [
"overlay:on",
"prepare",
"route",
"pipes",
"structures",
"plusStations",
'alignment:9:{"30.000":0.2}',
"culvert:aislo:draft:culvertopt:p1:7",
"carry:7->9", # 상단측은 넘김(carryAfterRouteSave)이 새 측점으로 옮긴 뒤 보냄(443-2)
"post:7:false:pipes,structures,alignment,culvertOptions,cross:route(carry 7)",
'alignment:7:{"30.000":0.2}',
"routeSaved",
"pipes:after",
"structures:after",
"plusStations:after",
"culvert:after:aislo:draft:culvertopt:p1:7",
"cross:carried:after",
"summary:9:2",
"toast:info",
"done:false",
@@ -121,24 +136,31 @@ def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out):
def test_노선이_그대로면_안_보냄(out):
assert "route" not in out["saveCalls"]
assert not any("route" in call for call in out["saveCalls"])
def test_노선_저장_실패는_멈추고_초안_남음(out):
assert out["routeFailOk"] is False
assert out["routeFailCalls"] == ["overlay:on", "route", "failed", "overlay:off"]
assert out["routeFailCalls"] == [
"overlay:on",
"post:7:false:pipes,structures,alignment,culvertOptions,cross:route(carry 7)",
'alignment:7:{"50.000":0.3}',
"failed", # 서버가 노선 차례에서 되돌림 · 정리 없음
"overlay:off",
]
assert out["routeFailDraft"] == {"50.000": 0.3} # 계획고 초안 그대로
def test_확정_중_노선이_바뀌면_다시_세우고_확정은_다시(out):
calls = out["routeConfirmCalls"]
assert calls[1] == "route" and calls[-2:] == ["rebuild", "overlay:off"]
assert calls[1].startswith("post:7:true:") and calls[1].endswith(":route(carry 7)")
assert calls[-2:] == ["rebuild", "overlay:off"]
assert "done:true" not in calls and "cross:true" not in calls
def test_노선_초안_칸_계획고가_새_노선으로(out):
"""346-3 덧 — 노선 초안 동안 계획고는 노선 0 칸(sub7) · 노선 저장 뒤 그 칸이 새 노선 9 로 · 0 · 7 칸 다 비움."""
assert out["draftRouteCalls"] == ['alignment:9:{"35.000":0.4}']
assert out["draftRouteCalls"] == ['alignment:7:{"35.000":0.4}'] # 서버가 새 노선 9 로
assert out["draftRouteLeft"] == {"draft": None, "old": None}
design = os.path.join(HERE, "..", "..", "A00_Common", "design")
with open(os.path.join(design, "design_pipeline_page.ts"), encoding="utf-8") as f:
@@ -18,6 +18,7 @@ def _read(relative: str) -> str:
REPO = _read("B06_Section/B06_Section_Repository.py")
B06_ROUTER = _read("B06_Section/B06_Section_Router.py")
REFRESH = _read("B06_Section/B06_Section_Cross_Refresh.ts")
KEYS = _read("B06_Section/logic/B06_Section_Design_Keys.ts")
B05_PREVIEW = _read("B05_Profile/B05_Profile_UI_Profile_Preview.ts")
# 설계 재계산 자리는 700줄 제한으로 `_UI_Page_Design_Sync` 로 떨어져 나갔다(2026-09-13).
B06_PAGE = _read("A00_Common/design/design_view_cross.ts") + _read(
@@ -66,7 +67,7 @@ def test_cross_refresh_preserves_user_fields():
"revet_link_detached",
"revet_follow_grade",
):
assert f'"{field}"' in REFRESH # preserveUserFields 의 보존 필드 목록
assert f'"{field}"' in KEYS # preserveUserFields 의 보존 필드 목록(한 벌 · 472-6)
def test_cross_refresh_lays_structure_areas_once():