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:
@@ -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:*`. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? {} };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 저장 뒤 페이지를 새로고침 없이 다시 세운다 — 편집기가 걸어 둔 길(옛 노선 캐시 비움 포함) · 없으면 라우터. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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차 범위).
|
||||
* · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -29,6 +29,8 @@ NODE_IDS = frozenset(
|
||||
"facility.follow",
|
||||
"cross.design",
|
||||
"cross.berm_spans",
|
||||
"facility.rebase",
|
||||
"stations.carry",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 분기).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_노선_반영_글자_없음():
|
||||
|
||||
@@ -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:
|
||||
# 저장된 추가 측점 = 이름 "" 비정규 측점 — 옛 자유 글 옮기기가 「기타」 구조물로 바꾸면 안 됨
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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_종단_보기_상단측_차례도_같은_창구():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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_공용_저장이_판_메모리를_안_보냄():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,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), (
|
||||
|
||||
@@ -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 가 목록을 안 씀"
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user