From 4b6800e38740eadd15b9fb62f6540b7fa01d9f09 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 12:07:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(472-6=206-B=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EB=B0=94=EB=80=90=20[=EC=A0=80=EC=9E=A5]=20=ED=95=9C=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8):=20=EB=85=B8=EC=84=A0=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=C2=B7=20=EA=B4=80=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=96=B9=EA=B8=B0=20=C2=B7=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EA=B3=A0=EC=B9=A8=20=EB=84=98=EA=B9=80=EC=9D=84=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=ED=95=9C=20=EC=B0=BD=EA=B5=AC=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=84=EB=B6=80=20=EC=95=84=EB=8B=88=EB=A9=B4=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /design/save 에 route(노선 초안) · carry(옛 노선 세션 칸) 칸 · 서버 = 노선 저장 → Node facility.rebase(rebasePipeDraft · rebaseStructureDraft) → 6-A 차례(새 노선) → Node stations.carry(routeCarryPatches) → 횡단 저장 - 되돌림 범위 = 노선 행 · 자식 표 · 단계 · 배수 폴더 · 노선 파일 · 지표면 확정(설계 지형 바꿈) - 손 상단측은 안 넘김(요구 274) · 노선 바뀐 [확정] = [저장](새 노선 본 뒤 다시) - 브라우저 = 노선 초안 + 사용자 칸만 한 본문 · 옛 차례 길(saveRouteDraft 뒤 flush · carryAfterRouteSave · keepDraftsOnNewRoute) · 부름 0 된 flush 창구 여섯 · saveCachedCrossPatches 걷음 - 등록부 R9 stations.carry · F10 facility.rebase · 옛 차례 단언 시험 한 본문 꼴로 · test_472_6_route_save(옛 길 = 새 길 · 실패 되돌림) Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_page_state.ts | 2 +- A00_Common/design/CLAUDE.md | 2 +- A00_Common/design/design_save.ts | 364 ++++------------- A00_Common/design/design_save_api.ts | 24 +- A00_Common/design/logic/design_calc_node.ts | 7 +- .../design/logic/design_calc_node_carry.ts | 48 +++ .../design/logic/design_calc_registry.ts | 14 + .../logic/design_calc_registry_facility.ts | 14 + B05_Profile/B05_Profile_Api_Fetch.ts | 30 +- B05_Profile/B05_Profile_Api_PageSettings.ts | 10 - B05_Profile/B05_Profile_Api_Pipes_Draft.ts | 10 - B05_Profile/B05_Profile_Api_PlusStations.ts | 10 - B05_Profile/B05_Profile_Api_Structures.ts | 12 - B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 113 ++---- B06_Section/B06_Section_Router_DesignSave.py | 73 +++- .../B06_Section_Router_DesignSave_Route.py | 123 ++++++ B06_Section/B06_Section_Section_Store.ts | 51 +-- .../B06_Section_UI_Page_Structures_Panel.ts | 2 +- B06_Section/CLAUDE.md | 1 + common_util/common_util_calc_runner.py | 2 + common_util/common_util_save_guard.py | 27 +- .../concepts/design_page/operations_b05.md | 4 +- .../concepts/design_page/operations_b06.md | 2 +- .../concepts/design_page/operations_common.md | 31 +- docs/wiki/concepts/design_page/rules.md | 3 +- .../tester/helper_323_9_definition_stamp.cjs | 11 +- resources/tester/helper_346_3_route_save.cjs | 38 +- resources/tester/helper_429_save_path.cjs | 21 +- .../tester/helper_442_2_page_settings.cjs | 47 ++- resources/tester/helper_443_save_stops.cjs | 15 +- resources/tester/helper_449_save_pipes.cjs | 32 +- resources/tester/helper_472_6_route_carry.mjs | 195 +++++++++ resources/tester/helper_472_6_route_entry.ts | 29 ++ resources/tester/helper_design_store_save.cjs | 57 ++- resources/tester/test_271_save_same.py | 5 +- .../tester/test_346_3_route_save_screen.py | 42 +- resources/tester/test_368_2_plus_stations.py | 8 +- resources/tester/test_429_save_path.py | 14 +- resources/tester/test_442_2_page_settings.py | 13 +- resources/tester/test_443_2_uphill_carry.py | 19 +- resources/tester/test_443_save_stops.py | 19 +- resources/tester/test_445_save_patch.py | 21 +- resources/tester/test_449_save_pipes.py | 22 +- resources/tester/test_472_3_calc_registry.py | 11 +- resources/tester/test_472_6_route_save.py | 380 ++++++++++++++++++ resources/tester/test_b06_save_channels.py | 35 +- resources/tester/test_b06_save_recompute.py | 4 +- resources/tester/test_design_store_save.py | 54 ++- 48 files changed, 1340 insertions(+), 731 deletions(-) create mode 100644 A00_Common/design/logic/design_calc_node_carry.ts create mode 100644 B06_Section/B06_Section_Router_DesignSave_Route.py create mode 100644 resources/tester/helper_472_6_route_carry.mjs create mode 100644 resources/tester/helper_472_6_route_entry.ts create mode 100644 resources/tester/test_472_6_route_save.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index b3f25c209..403b37745 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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:*`. */ diff --git a/A00_Common/design/CLAUDE.md b/A00_Common/design/CLAUDE.md index cfe66b8b1..d1d336745 100644 --- a/A00_Common/design/CLAUDE.md +++ b/A00_Common/design/CLAUDE.md @@ -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 다시 지어 시험 남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도): diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 5f2b2495e..9ca714cdb 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -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 = { +/** 차례 이름 — 실패하면 그 차례 이름째 알림(429 ⑨ · 초안은 세션에 남아 다음 [저장]에서 다시 나감). */ +const STEP_LABEL: Record = { route: "노선", settings: "페이지 설정", pipes: "배수관", @@ -136,11 +92,6 @@ const STEP_LABEL: Record = { 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 { - 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 { - 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, - projectId: string, - routeId: number, - fromRouteId: number = routeId, - confirm = false, -): () => Promise { - 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>["pipe_points"]; - -/** 세션 관 초안이 있으면 노선 저장 전 정본 관(id · 자리) — 없으면 · 못 받으면 null(얹기 안 함). */ -const savedPipesIfDraft = (projectId: string): Promise => - readPendingPipes(projectId) - ? fetchDetailPipePoints(projectId) - .then((detail) => detail.pipe_points) - .catch(() => null) - : Promise.resolve(null); - -/** 노선을 저장함 — 서버가 관을 새 노선으로 따라 옮김 · 새 교차 관을 놓음. 세션 관 초안(옛 노선 누가거리일 수 - * 있음)을 그 위에 얹어 둠 — 관 차례가 서버 따라감을 덮지 않게(349-0u). 못 받으면 초안 그대로. */ -async function rebasePendingPipes(projectId: string, before: SavedPipes): Promise { - 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>["structures"]; - -/** 세션 구조물 초안이 있으면 노선 저장 전 정본 — 없으면 · 못 받으면 null(얹기 안 함 · 459). */ -const savedStructuresIfDraft = (projectId: string): Promise => - readPendingStructures(projectId) - ? fetchStructures(projectId) - .then((list) => list.structures) - .catch(() => null) - : Promise.resolve(null); - -/** 노선을 저장함 — 서버가 구조물을 좌표로 따라 옮김. 세션 구조물 초안을 그 위에 얹음(459 · 관 짝) — 구조물 - * 차례가 서버 따라감을 브라우저 누가거리로 덮지 않게. 못 받으면 초안 그대로. */ -async function rebasePendingStructures(projectId: string, before: SavedStructures): Promise { - 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 { - 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> | 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 { 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 { + 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> = {}; + // 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀 + 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); diff --git a/A00_Common/design/design_save_api.ts b/A00_Common/design/design_save_api.ts index e999ac62b..4682568fa 100644 --- a/A00_Common/design/design_save_api.ts +++ b/A00_Common/design/design_save_api.ts @@ -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 | undefined) => Promise | void; } +/** 노선 저장 체인 시간 한도 — 옛 `/route/replan` 요청 한도와 같음(`B05_Profile_Api_Replan`). */ +const ROUTE_SAVE_TIMEOUT_MS = 15 * 60 * 1000; + const BODY_KEY: Record = { settings: "settings", pipes: "pipes", @@ -39,37 +43,43 @@ const BODY_KEY: Record = { cross: "cross", }; +/** 서버 차례 이름 — 본문 칸 차례 + 노선(6-B · 맨 앞). */ +export type SaveStepKey = SavePartKey | "route"; + export interface DesignSaveResult { waited: boolean; - steps: Partial>>; + /** 차례별 응답 · `route` = 노선 저장 결과 · `carry` = 넘김(`outside` = 노선 밖으로 버린 고침 수). */ + steps: Partial>>; } /** 실패 — 차례 이름(`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>, + extra: { route?: unknown; carry?: unknown } = {}, ): Promise { - const body: Record = { route_id: routeId, confirm }; + const body: Record = { 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 ?? {} }; diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 581953e42..8c353b06a 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -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[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 unknown> = { ...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①) + ...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B) // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. "structure.layouts": () => { const areas = structureAreaRows(sections); diff --git a/A00_Common/design/logic/design_calc_node_carry.ts b/A00_Common/design/logic/design_calc_node_carry.ts new file mode 100644 index 000000000..38fa33c08 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_carry.ts @@ -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 = { draft: T[]; before: T[]; after: T[] }; + +export interface CarryInputs { + rebase?: { pipes?: Rebase | null; structures?: Rebase | null }; + carry?: RouteCarryInput; +} + +/** id → 셈(입구 `RUNNERS` 에 얹음). */ +export function carryRunners(inputs: CarryInputs): Record 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), + }; +} diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index d21e1a5ea..79b0e5d37 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -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", diff --git a/A00_Common/design/logic/design_calc_registry_facility.ts b/A00_Common/design/logic/design_calc_registry_facility.ts index 96048f6f2..a0169494c 100644 --- a/A00_Common/design/logic/design_calc_registry_facility.ts +++ b/A00_Common/design/logic/design_calc_registry_facility.ts @@ -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", diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index b9ebc0adf..f1f330739 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -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 { - 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 { - 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 { const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId).catch(() => null)); @@ -273,15 +258,6 @@ export async function routePartsBody(projectId: string): Promise { - 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; diff --git a/B05_Profile/B05_Profile_Api_PageSettings.ts b/B05_Profile/B05_Profile_Api_PageSettings.ts index 763f2682a..f4625d1bf 100644 --- a/B05_Profile/B05_Profile_Api_PageSettings.ts +++ b/B05_Profile/B05_Profile_Api_PageSettings.ts @@ -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 { - 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); diff --git a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts index 19ffdc744..5b6deacee 100644 --- a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts +++ b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts @@ -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 { - 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 { diff --git a/B05_Profile/B05_Profile_Api_PlusStations.ts b/B05_Profile/B05_Profile_Api_PlusStations.ts index 6d51a3048..0ba88adc1 100644 --- a/B05_Profile/B05_Profile_Api_PlusStations.ts +++ b/B05_Profile/B05_Profile_Api_PlusStations.ts @@ -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 { - 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 { diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index b4355d8ef..febb3397e 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -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 { - 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( diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index e08a53272..7e2ef3ed6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -2,34 +2,30 @@ * 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 { 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 +116,22 @@ export async function pendingRouteSave(projectId: string): Promise= 2 ? { draft } : null; } -/** 노선 초안 저장 — 실패하면 throw(서버가 되살림 · 화면 초안 그대로). 새 노선 번호 · 결과를 돌려줌. */ +/** 노선 초안 저장(추천 [적용]) — 실패하면 throw(서버가 되살림 · 화면 초안 그대로). 새 노선 번호 · 결과를 돌려줌. */ export async function saveRouteDraft( projectId: string, body: RouteSaveBody, ): Promise { - pipesBeforeSave.set(projectId, await savedPipes(projectId)); const saved = await replanRoute(projectId, body); + routeSaved(projectId); + return saved; +} + +/** 노선을 저장함(서버가 다 저장한 뒤) — 가게 노선 초안 비움 · 조회 결과 캐시 비움(옛 노선 것 · 추천은 새 노선의 + * 출발점이라 다시 적음) · 편집기 「저장됨」. */ +export function routeSaved(projectId: string): void { useDesignStore(projectId, null).set("routeDraft", null); - // 노선이 바뀌면 조회 결과 캐시는 옛 노선 것 — 추천은 새 노선의 출발점이라 다시 적는다. clearResults(projectId); sources.get(projectId)?.saved(); - return saved; } /** 저장 결과 한 줄 — 관 지킴 · 새 교차 관 · 구조물 옮김 · 멀어짐 · 노선 밖 고침 · 배수유역 낡음. */ @@ -148,59 +148,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(); - -const savedPipes = (projectId: string): Promise => - 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 { - 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; 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; } /** 저장 뒤 페이지를 새로고침 없이 다시 세운다 — 편집기가 걸어 둔 길(옛 노선 캐시 비움 포함) · 없으면 라우터. */ diff --git a/B06_Section/B06_Section_Router_DesignSave.py b/B06_Section/B06_Section_Router_DesignSave.py index 3abdb1ca2..e29b95a64 100644 --- a/B06_Section/B06_Section_Router_DesignSave.py +++ b/B06_Section/B06_Section_Router_DesignSave.py @@ -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 ()) ) diff --git a/B06_Section/B06_Section_Router_DesignSave_Route.py b/B06_Section/B06_Section_Router_DesignSave_Route.py new file mode 100644 index 000000000..99901482f --- /dev/null +++ b/B06_Section/B06_Section_Router_DesignSave_Route.py @@ -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") diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index f1c58c945..efc927692 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -36,7 +36,6 @@ import type { CrossSection, CrossSectionPatch, SectionDetailResponse, - StandardCrossSection, } from "./B06_Section_Api_Fetch"; import { forgetCanon, @@ -48,7 +47,7 @@ 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 } from "./B06_Section_Api_Culvert_Options"; import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types"; @@ -65,10 +64,6 @@ import { } from "./B06_Section_Station_Keys"; 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"; @@ -470,47 +465,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 { - 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; -} diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index c25586145..9487f6718 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -5,7 +5,7 @@ * * · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로. * · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고 - * [임시저장]·[확정]의 `flushPendingStructures`가 정본에 쓴다(캐시→저장 원칙). + * [저장]·[확정] 한 본문(`structuresPart`)이 정본에 쓴다(캐시→저장 원칙). * · 계곡 통과 시설(A군, pipe_points 정본)은 **표시·선택만** — 추가·이동·삭제는 * 배수유역 재분할 체인이 있는 B05 몫이라 안내만 한다(2026-08-29 1차 범위). * · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설 diff --git a/B06_Section/CLAUDE.md b/B06_Section/CLAUDE.md index 9d4f5e231..1243b4846 100644 --- a/B06_Section/CLAUDE.md +++ b/B06_Section/CLAUDE.md @@ -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 금지 남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도): diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index 7686ac7ff..4c11b847a 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -29,6 +29,8 @@ NODE_IDS = frozenset( "facility.follow", "cross.design", "cross.berm_spans", + "facility.rebase", + "stations.carry", } ) diff --git a/common_util/common_util_save_guard.py b/common_util/common_util_save_guard.py index d5dc91378..f40877474 100644 --- a/common_util/common_util_save_guard.py +++ b/common_util/common_util_save_guard.py @@ -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 ()), ] diff --git a/docs/wiki/concepts/design_page/operations_b05.md b/docs/wiki/concepts/design_page/operations_b05.md index a5b2c7764..412c38dde 100644 --- a/docs/wiki/concepts/design_page/operations_b05.md +++ b/docs/wiki/concepts/design_page/operations_b05.md @@ -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` | diff --git a/docs/wiki/concepts/design_page/operations_b06.md b/docs/wiki/concepts/design_page/operations_b06.md index 285838845..9fe64906c 100644 --- a/docs/wiki/concepts/design_page/operations_b06.md +++ b/docs/wiki/concepts/design_page/operations_b06.md @@ -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` | diff --git a/docs/wiki/concepts/design_page/operations_common.md b/docs/wiki/concepts/design_page/operations_common.md index d92a917bf..1f68b6776 100644 --- a/docs/wiki/concepts/design_page/operations_common.md +++ b/docs/wiki/concepts/design_page/operations_common.md @@ -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:520`). diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index e65aa9886..0008a160a 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -49,9 +49,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` | 코드 확인 | diff --git a/resources/tester/helper_323_9_definition_stamp.cjs b/resources/tester/helper_323_9_definition_stamp.cjs index 2c6c36483..d0b9627e8 100644 --- a/resources/tester/helper_323_9_definition_stamp.cjs +++ b/resources/tester/helper_323_9_definition_stamp.cjs @@ -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, diff --git a/resources/tester/helper_346_3_route_save.cjs b/resources/tester/helper_346_3_route_save.cjs index f23f3cafe..18c47a8a6 100644 --- a/resources/tester/helper_346_3_route_save.cjs +++ b/resources/tester/helper_346_3_route_save.cjs @@ -38,32 +38,18 @@ const STUBS = { }, "../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 +139,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 · 잠금 막 · 초안 비움 · 다시 세움 diff --git a/resources/tester/helper_429_save_path.cjs b/resources/tester/helper_429_save_path.cjs index 941f5a7ed..3805baf27 100644 --- a/resources/tester/helper_429_save_path.cjs +++ b/resources/tester/helper_429_save_path.cjs @@ -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"); diff --git a/resources/tester/helper_442_2_page_settings.cjs b/resources/tester/helper_442_2_page_settings.cjs index cb53502db..d816d89e1 100644 --- a/resources/tester/helper_442_2_page_settings.cjs +++ b/resources/tester/helper_442_2_page_settings.cjs @@ -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; diff --git a/resources/tester/helper_443_save_stops.cjs b/resources/tester/helper_443_save_stops.cjs index 17ae3b82d..8b2aed18f 100644 --- a/resources/tester/helper_443_save_stops.cjs +++ b/resources/tester/helper_443_save_stops.cjs @@ -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; } diff --git a/resources/tester/helper_449_save_pipes.cjs b/resources/tester/helper_449_save_pipes.cjs index 51d94156f..c90a261b1 100644 --- a/resources/tester/helper_449_save_pipes.cjs +++ b/resources/tester/helper_449_save_pipes.cjs @@ -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; } diff --git a/resources/tester/helper_472_6_route_carry.mjs b/resources/tester/helper_472_6_route_carry.mjs new file mode 100644 index 000000000..05b30815e --- /dev/null +++ b/resources/tester/helper_472_6_route_carry.mjs @@ -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)); diff --git a/resources/tester/helper_472_6_route_entry.ts b/resources/tester/helper_472_6_route_entry.ts new file mode 100644 index 000000000..c78f04b57 --- /dev/null +++ b/resources/tester/helper_472_6_route_entry.ts @@ -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"; diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 3298ac518..3733bc8ff 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -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; diff --git a/resources/tester/test_271_save_same.py b/resources/tester/test_271_save_same.py index 786a6779d..1ad16624d 100644 --- a/resources/tester/test_271_save_same.py +++ b/resources/tester/test_271_save_same.py @@ -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 diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index 527e91883..a4694728b 100644 --- a/resources/tester/test_346_3_route_save_screen.py +++ b/resources/tester/test_346_3_route_save_screen.py @@ -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", "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_노선_반영_글자_없음(): diff --git a/resources/tester/test_368_2_plus_stations.py b/resources/tester/test_368_2_plus_stations.py index 6f0db4116..a64afceb3 100644 --- a/resources/tester/test_368_2_plus_stations.py +++ b/resources/tester/test_368_2_plus_stations.py @@ -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: # 저장된 추가 측점 = 이름 "" 비정규 측점 — 옛 자유 글 옮기기가 「기타」 구조물로 바꾸면 안 됨 diff --git a/resources/tester/test_429_save_path.py b/resources/tester/test_429_save_path.py index bf7bc31e0..27789064f 100644 --- a/resources/tester/test_429_save_path.py +++ b/resources/tester/test_429_save_path.py @@ -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): diff --git a/resources/tester/test_442_2_page_settings.py b/resources/tester/test_442_2_page_settings.py index 494919197..2eb84390f 100644 --- a/resources/tester/test_442_2_page_settings.py +++ b/resources/tester/test_442_2_page_settings.py @@ -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): diff --git a/resources/tester/test_443_2_uphill_carry.py b/resources/tester/test_443_2_uphill_carry.py index 098311b45..bb7f6c1cc 100644 --- a/resources/tester/test_443_2_uphill_carry.py +++ b/resources/tester/test_443_2_uphill_carry.py @@ -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 \ No newline at end of file diff --git a/resources/tester/test_443_save_stops.py b/resources/tester/test_443_save_stops.py index 8a786a240..67b9aee98 100644 --- a/resources/tester/test_443_save_stops.py +++ b/resources/tester/test_443_save_stops.py @@ -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_종단_보기_상단측_차례도_같은_창구(): diff --git a/resources/tester/test_445_save_patch.py b/resources/tester/test_445_save_patch.py index 6df4017cd..9afe1f1e8 100644 --- a/resources/tester/test_445_save_patch.py +++ b/resources/tester/test_445_save_patch.py @@ -231,17 +231,22 @@ 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("crossdesign")', + 'read("rockb")', + 'read("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 diff --git a/resources/tester/test_449_save_pipes.py b/resources/tester/test_449_save_pipes.py index 62f046175..bdff7e363 100644 --- a/resources/tester/test_449_save_pipes.py +++ b/resources/tester/test_449_save_pipes.py @@ -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_공용_저장이_판_메모리를_안_보냄(): diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index 6cd971cdf..4572d363f 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -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 diff --git a/resources/tester/test_472_6_route_save.py b/resources/tester/test_472_6_route_save.py new file mode 100644 index 000000000..2f736fc16 --- /dev/null +++ b/resources/tester/test_472_6_route_save.py @@ -0,0 +1,380 @@ +"""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"(? 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 diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index 67c0cf7ca..8683f8af3 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -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), ( diff --git a/resources/tester/test_b06_save_recompute.py b/resources/tester/test_b06_save_recompute.py index ad3922185..3d140f9b1 100644 --- a/resources/tester/test_b06_save_recompute.py +++ b/resources/tester/test_b06_save_recompute.py @@ -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)"), ( "저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다" ) diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index 1ed386508..75e5606a2 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -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: