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_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index ab0676d89..3a1c0b7fc 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -130,6 +130,7 @@ interface PagePipeline { detail(): SectionDetailResponse | null; /** 노선 초안 버림 동안(그리기 멈춤 ~ 풂) — 저장 노선 낡음 다시 셈을 안 띄움(472-5 덧). */ holding(): boolean; + commit(): void; dispose(): void; } @@ -184,6 +185,9 @@ export const pipelineDetail = (projectId: string): SectionDetailResponse | null export const pagePipeline = (projectId: string): DesignPipeline | null => pages.get(projectId)?.pipe ?? null; +/** 노선 초안을 저장함(472-6 6-B) — 초안 전 관 · 구조물을 버림(노선 초안이 비어도 「버림」 되돌림 안 함). */ +export const commitRouteFacilities = (projectId: string): void => pages.get(projectId)?.commit(); + export function disposePagePipeline(projectId: string): void { pages.get(projectId)?.dispose(); pages.delete(projectId); @@ -676,6 +680,7 @@ function createPagePipeline( ownsCross: Boolean(options.savedCross), detail: () => draftDetailOf(projectId), holding: () => holding, + commit: () => void (beforeDraft = null), dispose() { disposed = true; setDraftSectionDetail(projectId, null); 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/design_selection.ts b/A00_Common/design/design_selection.ts index df7a528ef..15251543f 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -8,13 +8,14 @@ * * 값은 등록표 키 `structure-pick`(② 설계 초안 · 페이지를 오가도 남음)에 실린다. * 어느 화면(3D · 종단 알약 · 목록 · 횡단 카드)에서 골라도 같은 값이 적히고, 다른 쪽은 - * 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 도 여기 한 값뿐이다. + * 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 은 여기서 내줌(정의 = common_util). * ========================================================================== */ +import { PIPE_MATCH_M } from "../../common_util/common_util_pipe_match"; import { readState, writeState } from "../b_page_state"; -/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */ -export const PIPE_MATCH_M = 0.51; +/** 관 · 구조물 찾는 거리(m) — 정의 = `common_util_pipe_match`(서버 Node 묶음도 같은 값 · 472-6). */ +export { PIPE_MATCH_M }; export interface DesignSelection { chainageM: number | null; 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..6bcaaa925 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -2,34 +2,31 @@ * B05_Profile_UI_RouteEdit_Apply.ts * 노선 편집(평면)의 **노선 저장 이음**(346-3) — [노선 반영] 단추는 없앰. * - * · 페이지 [저장](`design_save`)이 맨 앞에서 `pendingRouteSave` 로 지금 노선 초안을 받아 `saveRouteDraft` - * (`POST /route/replan` · draft = 계약 `RouteDraft`)로 보낸다 — 노선이 마지막 저장 그대로면 null(안 보냄). - * · 저장 뒤 `carryAfterRouteSave` 가 세션 횡단 초안을 측점 열쇠로 새 노선에 넘기고(`carryStationDrafts`) - * 넘긴 고침을 저장 · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`). + * · 페이지 [저장](`design_save`)이 `pendingRouteSave` 로 지금 노선 초안을 받아 한 본문(`POST /design/save` · + * `route` 칸 = `RouteSaveBody`)에 싣는다 — 노선이 마지막 저장 그대로면 null(안 보냄 · 472-6 6-B). + * · 서버가 노선 저장 → 관 · 구조물 다시 얹기 → 세션 횡단 고침 넘김(`routeCarryPart` 가 낸 옛 노선 칸 · 같은 TS)까지 + * 한 번에 · 다 저장한 뒤 `routeSaved` · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`). * · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정). * 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나). * 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽). * ========================================================================== */ -import { clearDrafts, clearResults, readState } from "../A00_Common/b_page_state"; +import { clearDrafts, clearResults, clearState, readState } from "../A00_Common/b_page_state"; import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; import { useDesignStore } from "../A00_Common/design/design_store"; +import { commitRouteFacilities } from "../A00_Common/design/design_pipeline_page"; import { currentRoute, remountRoute } from "../A00_Common/router"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store"; +import { routeCarryInputs } from "../B06_Section/B06_Section_Station_Keys"; import { - invalidateSectionDetail, - loadSectionDetail, - saveCachedCrossPatches, -} from "../B06_Section/B06_Section_Section_Store"; -import { - carryChainageStates, - carryStationDrafts, - useStationKeys, -} from "../B06_Section/B06_Section_Station_Keys"; -import { withCarriedEdits, withDraftAdjusts } from "../B06_Section/B06_Section_Section_Adjusts"; -import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids"; -import { clearRouteLatestCache, flushUphillOverrides } from "./B05_Profile_Api_Fetch"; + sectionConfirmBody, + type StandardCrossSection, +} from "../B06_Section/B06_Section_Api_Fetch"; +import { readStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel"; +import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; +import type { SavePart } from "../A00_Common/design/design_save_api"; +import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch"; import { replanRoute, type RouteReplanResponse, @@ -120,18 +117,24 @@ export async function pendingRouteSave(projectId: string): Promise= 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 { + commitRouteFacilities(projectId); useDesignStore(projectId, null).set("routeDraft", null); - // 노선이 바뀌면 조회 결과 캐시는 옛 노선 것 — 추천은 새 노선의 출발점이라 다시 적는다. clearResults(projectId); sources.get(projectId)?.saved(); - return saved; } /** 저장 결과 한 줄 — 관 지킴 · 새 교차 관 · 구조물 옮김 · 멀어짐 · 노선 밖 고침 · 배수유역 낡음. */ @@ -148,59 +151,32 @@ export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): strin return `노선 저장 — ${parts.join(" · ")}`; } -type SavedPipe = { id?: string; chainage_m: number }; -/** 노선 저장 직전 저장 관(id · 옛 누가거리) — 저장 뒤 같은 id 의 새 자리로 조절을 옮김(초안으로 안 따라간 고침 · 417). */ -const pipesBeforeSave = new Map(); - -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_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 21aded88f..91956d0f2 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -31,6 +31,10 @@ import { } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import type { SavePart } from "../A00_Common/design/design_save_api"; +import { STRUCTURE_ADJUST_KEYS } from "./logic/B06_Section_Design_Keys"; + +/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 목록 한 벌 = `logic/B06_Section_Design_Keys`(472-6). */ +export { STRUCTURE_ADJUST_KEYS }; /** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로. * 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */ @@ -101,19 +105,6 @@ export function culvertOptionsPart(sessionKey: string | null): SavePart | null { return { body: pending, after: () => writePending(sessionKey, {}) }; } -/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */ -export const STRUCTURE_ADJUST_KEYS = [ - "inlet_structure", - "basin_adjust", - "revet_adjust", - "ford_adjust", - "box_adjust", - "extra_wall_counts", - "extra_spans", - "revet_link_detached", - "revet_follow_grade", -] as const; - /** 측점 조정값 세션 — 열쇠가 누가거리(`b` · `:역할` 붙은 것 포함)로 시작함. */ const STATION_ADJUST_STATES: readonly StateName[] = [ "culvertopt", diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 635bb9f80..1d5887eec 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -43,6 +43,7 @@ import { type BermSpec, } from "@util/common_util_cross_berm"; import { readState } from "../A00_Common/b_page_state"; +import { USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys"; import { savedDesigns } from "./B06_Section_Section_Canon"; import { crossDesignChoices, @@ -89,44 +90,10 @@ export interface CrossRefreshInput { shouldApply?: () => boolean; } -/** - * 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로 - * 실어 보낸다(`B06_Section_Section_Store`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS` - * 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`). - * - * 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을 - * 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). - */ -export const USER_TOUCHED_KEYS = [ - "display_half_width_m", - "inlet_structure", - "basin_adjust", - "revet_adjust", - "ford_adjust", - "box_adjust", - "extra_wall_counts", - "extra_spans", - "revet_link_detached", - "revet_follow_grade", - // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). - "berm", - // 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다. - "cut_slope_ratio_user", - // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. - "structure_note", - // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. - "structure_note_cad", - // [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) - "detail_table", - // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 - "shoulder_extend_left_m", - "shoulder_extend_right_m", - // 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김 - "berm_depth_left_m", - "berm_depth_right_m", -] as const; +/** 계산이 만들지 않는 **사용자 값** — 목록 한 벌 = `logic/B06_Section_Design_Keys`(서버 Node 입구도 · 472-6). */ +export { USER_TOUCHED_KEYS }; -/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ +/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */ const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; /** diff --git a/B06_Section/B06_Section_Route_Carry_Patch.ts b/B06_Section/B06_Section_Route_Carry_Patch.ts new file mode 100644 index 000000000..7b6947a06 --- /dev/null +++ b/B06_Section/B06_Section_Route_Carry_Patch.ts @@ -0,0 +1,436 @@ +/* ============================================================================= + * B06_Section_Route_Carry_Patch.ts + * 세션 고침 → 상세 설계 칸 얹기 · [저장] 횡단 patch 꼴 **한 벌**(472-6 6-B) — 화면 · 세션 · fetch 없음. + * + * · `layDraftAdjusts` · `layCarriedEdits` — 조정창 · 옮긴 고침 칸(읽는 함수를 받음 · 브라우저 세션 겉 = + * `B06_Section_Section_Adjusts`) · `layPipeOptions` — 관 옵션의 조정값 몫(387-4b). + * · `crossPatchesFromCache` — 캐시 설계의 사용자 칸 = [저장] patch 바탕(445 · 상세 가게가 내줌). + * · `routeCarryPatches` — 노선 바뀐 [저장]: 옛 노선 세션 칸 → 새 노선 서버 상세(측점 열쇠 넘김 + * `logic/B06_Section_Route_Carry`) → 얹기 → patch(옛 브라우저 길 `carryAfterRouteSave` 와 같은 차례) · + * 서버 Node 입구 `stations.carry` 가 부름(`A00_Common/design/logic/design_calc_node_carry.ts`). + * logic 밖 = 구조물 그림 기하(관 벽 · 단 · 구간 · 세월 · BOX 기본값)를 끔 — 서버 Node 묶음 알려진 끌림. + * ========================================================================== */ + +import type { + CrossDesign, + CrossSectionPatch, + SectionDetailResponse, +} from "./B06_Section_Api_Fetch"; +import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom"; +import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; +import { PIPE_MATCH_M } from "../common_util/common_util_pipe_match"; +import { inferStationInterval } from "../common_util/common_util_svg"; +import { applySpanPatch, WALL_MIN_PURE_HEIGHT_M } from "./logic/B06_Section_UI_Cross_Culvert_Const"; +import { tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { computeStoredLayouts } from "./B06_Section_Structure_Layouts"; +import { ROW_CARRY_KEYS } from "./logic/B06_Section_Station_Swap"; +import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches"; +import { STRUCTURE_ADJUST_KEYS, USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys"; +import { + carryChainageStates, + carryKeyedStates, + readKeyedIn, + stationKeysFrom, + type Keyed, + type KeyedState, + type Owner, + type Raw, +} from "./logic/B06_Section_Route_Carry"; + +export type Cells = Record; +/** 누가거리(숫자) → 쪽마다 값(세션에 있는 쪽만 · 노견 확장 · 소단 깊이). */ +export type SideValues = ReadonlyMap>>; +type Obj = Record; +const INLET = ["auto", "revet", "I", "L", "U"]; +const isObj = (value: unknown): value is Obj => !!value && typeof value === "object"; +const count = (value: unknown): number => + Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0; + +/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */ +function wallAdjust(value: unknown): WallAdjust | null { + if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value }; + if (!isObj(value)) return null; + const partial = value as Partial; + return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null }; +} + +/** 저장 안 한 조정창 값(누가거리 열쇠 칸 · `read`)을 상세 설계 칸에 얹음 — 조정 4축 · 형식 · 단 수 · 구간 · 연동 · + * 세월교 · BOX(387-4 · 브라우저 세션 = `withDraftAdjusts` · 서버 = 노선 바뀐 [저장] 넘김 · 472-6). */ +export function layDraftAdjusts( + detail: SectionDetailResponse, + read: (name: string) => Cells, +): void { + const revet = read("revetx"); + const basin = read("basinadjust"); + const inlet = read("inletstruct"); + const extra = read("extrawall"); + const spans = read("extraspan"); + const ford = read("fordadjust"); + const box = read("boxadjust"); + const link = read("revetlink") as { detached?: Cells; grade?: Cells }; + for (const section of detail.cross_sections) { + const design = section.design as (CrossDesign & Obj) | null | undefined; + if (!design) continue; + const at = section.chainage_m.toFixed(2); + const own = (cells: Cells): Array<[string, unknown]> => + Object.entries(cells) + .filter(([key]) => key.startsWith(`${at}:`)) + .map(([key, value]) => [key.slice(at.length + 1), value]); + for (const [role, value] of own(revet)) { + const adjust = wallAdjust(value); + if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust }; + else if (value === null && design.revet_adjust?.[role]) { + // null = 되돌림 — 저장분 그 벽을 뺌(445 M2) + const { [role]: _gone, ...rest } = design.revet_adjust; + design.revet_adjust = rest; + } + } + if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string)) + design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"]; + if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] }; + // 세션에 있는 쪽만 — 없는 쪽은 저장분 그대로(445 H3) + const saved = design.extra_wall_counts; + if (at in extra || `b${at}` in extra) + design.extra_wall_counts = { + outlet: at in extra ? count(extra[at]) : (saved?.outlet ?? 0), + basin: `b${at}` in extra ? count(extra[`b${at}`]) : (saved?.basin ?? 0), + }; + for (const [wall, value] of own(spans)) { + const span = value as { lengthM: number; beforeM: number; afterM: number } | null; + if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue; + design.extra_spans = { + ...(design.extra_spans ?? {}), + [wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM }, + }; + } + if (isObj(link.detached) && at in link.detached) + design.revet_link_detached = !!link.detached[at]; + if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at]; + if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]); + const sides = box[at] as { left?: Obj; right?: Obj } | undefined; + if (isObj(sides)) + design.box_adjust = { + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right }, + }; + } +} + +/** 노선 저장 뒤 새 노선으로 옮긴 세션 고침(417) — 사면 쉬프트 · 소단 깊이 · 측점 폭 · 메모 · 상세 표를 설계 칸에 얹음 · + * 같은 [저장]의 횡단 패치(`crossPatchesFromCache`)가 실음. 조절 4축 · 형식은 `layDraftAdjusts`(브라우저 세션 = + * `withCarriedEdits`). */ +export function layCarriedEdits( + detail: SectionDetailResponse, + read: (name: string) => Cells, + sideValues: { shoulder: SideValues; berm: SideValues }, +): void { + const cells: Array<[string, Cells]> = [ + ["display_half_width_m", read("crossw")], + ["structure_note", read("structure-notes")], + ["structure_note_cad", read("structure-note-cads")], + ["detail_table", read("detail-table")], + ]; + const sides: Array<[string, SideValues]> = [ + ["shoulder_extend", sideValues.shoulder], + ["berm_depth", sideValues.berm], + ]; + for (const section of detail.cross_sections) { + const design = section.design as (CrossDesign & Obj) | null | undefined; + if (!design) continue; + const at = section.chainage_m.toFixed(2); + for (const [field, values] of cells) if (values[at] !== undefined) design[field] = values[at]; + for (const [field, values] of sides) { + const own = values.get(Number(at)); + for (const side of ["left", "right"] as const) + if (own?.[side] !== undefined) design[`${field}_${side}_m`] = own[side]; + } + } +} + +/** + * 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름). + * + * 초기 계산값은 원복용으로 서버에 그대로 있고, 사용자가 만진 값은 이 캐시 한 세트가 + * 최종본이다. B05 [임시저장]도 이 세트를 함께 보내야 B06에서 만진 구조물 조정이 + * 영구저장소에 남는다 — 안 보내면 캐시만 비워져 편집이 사라진다. + * + * 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)를 측점마다 **통째로** 싣는다 — [저장] patch 의 바탕 + * (445 H3 · 세션 고침은 그 위에 `mergeCrossPatches`). `savedAt`(서버 행 누가거리)을 주면 그 자리 행이 없는 + * 측점(옮긴 관 · 구조물 · 새 측점 = 서버가 기본 설계로 새로 세움)은 지반 등 입력(`ROW_CARRY_KEYS`)도 실음(445 H2). + */ +export function crossPatchesFromCache( + detail: SectionDetailResponse, + kindChanged: readonly number[] = [], + savedAt?: readonly number[], +): CrossSectionPatch[] { + const patches: CrossSectionPatch[] = []; + // 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값은 서버가 비움 · 캐시에 남은 옛 값을 다시 안 실음 + const structureKeys: readonly string[] = STRUCTURE_ADJUST_KEYS; + const reset = (at: number) => kindChanged.some((c) => Math.abs(c - at) < PIPE_MATCH_M); + const rowless = (at: number) => !!savedAt && savedAt.every((c) => Math.abs(c - at) >= 0.01); + for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true }); + for (const section of detail.cross_sections) { + const design = section.design; + if (!design) continue; + const patch: CrossSectionPatch = { chainage_m: section.chainage_m }; + const skip = reset(section.chainage_m); + let touched = false; + // 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고 + // 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이 + // 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). + const source = design as unknown as Record; + const target = patch as unknown as Record; + const keys = rowless(section.chainage_m) + ? [...USER_TOUCHED_KEYS, ...ROW_CARRY_KEYS] + : USER_TOUCHED_KEYS; + for (const key of keys) { + const value = source[key]; + if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue; + target[key] = value; + touched = true; + } + if (touched) patches.push(patch); + } + return patches; +} + +/** 관 하나 — 자리 · 폼 칸(관 초안 · 저장 관 `pipe_points` 같은 꼴). */ +export interface OptionPipe { + chainage_m: number; + options?: Record | null; +} + +const isDefault = (a: WallAdjust): boolean => + Math.abs(a.x) < 1e-9 && a.d == null && a.h == null && a.m == null; +const round1 = (value: number): number => Math.round(value * 10) / 10; + +/** 조정창 `update` 와 같은 꼴 — x · d 는 그대로 · h · m 만(폼 한 칸이 주인 · 빈 칸 = 자동 null). */ +function setWall(design: CrossDesign, role: string, h: number | null, m: string | null): void { + const current = { ...ZERO_ADJUST, ...(design.revet_adjust?.[role] ?? {}) } as WallAdjust; + const next: WallAdjust = { ...current, h: h === null ? null : round1(h), m }; + const bucket = { ...(design.revet_adjust ?? {}) }; + if (isDefault(next)) delete bucket[role]; + else bucket[role] = next; + design.revet_adjust = bucket; +} + +/** 관 옵션의 조정값 몫을 상세에 얹음(폼 칸 전부 = 되보내기와 같은 규칙) — 같은 값이면 그대로. */ +export function layPipeOptions( + detail: SectionDetailResponse, + pipes: ReadonlyArray, +): void { + const sections = detail.cross_sections; + for (const pipe of pipes) { + const options = pipe.options; + if (!options) continue; + const near = (at: number): boolean => Math.abs(at - pipe.chainage_m) < PIPE_MATCH_M; + // 물넘이포장 = 제원만(66-2) · 벽 없음 + if (sections.some((s) => s.ford_pavement && near(s.ford_pavement.chainage_m ?? s.chainage_m))) + continue; + const owner = sections.find((s) => near(s.chainage_m)); + const culvert = owner?.culvert; + const design = owner?.design; + if (!owner || !culvert || !design) continue; + const num = (key: string): number | undefined => { + const value = Number(options[key]); + return Number.isFinite(value) ? value : undefined; + }; + for (const role of ["inlet", "outlet"] as const) { + const form = options[`${role}_revet_form`]; + setWall( + design, + role, + num(`${role}_revet_height_m`) ?? null, + typeof form === "string" ? form : null, + ); + } + if (culvert.hidden_pipe && !owner.ford && !owner.box) continue; + // 흙막이 n 단 = 지형이 세운 유출 다단(`pipeExtraCount` 와 같은 셈) + const outlet = design.extra_wall_counts?.outlet ?? 0; + const probe = { + ...owner, + design: { ...design, extra_wall_counts: { basin: 0, ...design.extra_wall_counts, outlet } }, + }; + const tiers = outlet + ? (computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet) + : 0; + for (let n = 1; n <= tiers; n++) { + const role = `extra${n - 1}`; + const value = (item: string): number | undefined => { + const raw = options[`soil_add_${n}_${item}`]; + return raw === undefined || raw === "" || !Number.isFinite(Number(raw)) + ? undefined + : Number(raw); + }; + const form = options[`soil_add_${n}_form`]; + const height = value("height"); + setWall( + design, + role, + height === undefined ? null : Number(Math.max(height, WALL_MIN_PURE_HEIGHT_M).toFixed(1)), + typeof form === "string" && form ? form : null, + ); + const span = { lengthM: value("length"), beforeM: value("before"), afterM: value("after") }; + if (Object.values(span).every((v) => v === undefined)) continue; + const at = tierSpanOf(owner, role); + const current = { + lengthM: Math.round((at.beforeM + at.afterM) * 10) / 10, + beforeM: Math.round(at.beforeM * 10) / 10, + afterM: Math.round(at.afterM * 10) / 10, + }; + const next = applySpanPatch(current, span); + design.extra_spans = { + ...(design.extra_spans ?? {}), + [role]: { length_m: next.lengthM, before_m: next.beforeM, after_m: next.afterM }, + }; + } + } +} + +/** 노선 바뀐 [저장] 넘김 입력(브라우저 `routeCarryInputs` + 서버 저장분 · 472-6 6-B). */ +export interface RouteCarryInput { + /** 새 노선 서버 상세 — 관 · 구조물 · 추가 측점 · 계획고 차례 뒤(옛 길 `loadSectionDetail` 과 같은 때). */ + detail: SectionDetailResponse; + /** 옛 노선 세션 칸 원본 — 측점 열쇠 칸 다섯 · 누가거리 칸 · `kindreset`(상단측 뺌 · 요구 274). */ + states: Record; + /** 옛 노선 측점 열쇠 목록(브라우저가 세운 것 · 없으면 null). */ + from_keys: Keyed[] | null; + /** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */ + owners: Owner[]; + /** 관 세션 장부(`pipe-ids`). */ + pipe_ids: { next: number; ids: Array<{ id: string; at: number }> } | null; + /** 노선 저장 직전 저장 관(id · 누가거리). */ + pipes_before: Array<{ id?: string | null; chainage_m: number }>; + /** 관 차례 뒤 저장 관(id · 누가거리 · 옵션). */ + pipes_after: Array; +} + +/** 관 세션 장부 사본 — 찾기 · 없으면 새 차례(브라우저 `pipeIdNear` · `pipeIdAt` · `pipeChainageOf` 와 같은 규칙). */ +function pipeBook(book: RouteCarryInput["pipe_ids"]) { + const ids = [...(book?.ids ?? [])]; + let next = book?.next ?? 1; + const near = (at: number): string | null => { + let best: { id: string; at: number } | undefined; + let gap = PIPE_MATCH_M; + for (const entry of ids) + if (Math.abs(entry.at - at) < gap) [best, gap] = [entry, Math.abs(entry.at - at)]; + return best?.id ?? null; + }; + return { + near, + at: (at: number): string => { + const hit = near(at); + if (hit) return hit; + const id = `pipe-s${next++}`; + ids.push({ id, at }); + return id; + }, + chainageOf: (id: string): number | null => { + if (!id.startsWith("pipe-")) return null; + const hit = ids.find((entry) => entry.id === id); + if (hit) return hit.at; + const legacy = Number(id.slice("pipe-".length)); + return Number.isFinite(legacy) ? legacy : null; + }, + }; +} + +/** 누가거리(0.01 m 글) 열쇠 맵 → 누가거리(숫자) 맵(값이 있는 것만). */ +function byChainage(cells: ReadonlyMap): Map { + const out = new Map(); + for (const [key, value] of cells) { + const chainage = Number(key); + if (Number.isFinite(chainage) && value) out.set(chainage, value); + } + return out; +} + +/** 카드 선택 한 벌(세션 `crossdesign` · `CrossDesignChoice` 꼴). */ +interface CardChoice { + ground_type?: string; + section_mode?: string; + ditch_side?: string | null; + ditch_type?: string | null; + paved?: boolean; + two_stage_slope?: boolean; + ditch_choice?: boolean | null; +} + +type Sides = Partial>; + +/** + * 노선 바뀐 [저장] 횡단 patch — 옛 브라우저 길(`carryAfterRouteSave` → `saveCachedCrossPatches`)과 같은 차례: + * 새 측점 열쇠 → 관 옵션 얹기 → 측점 열쇠 칸 · 누가거리 칸 넘김 → 조정 · 옮긴 고침 얹기 → 캐시 사용자 칸 바탕 + + * 카드 선택 · 암선 · 암 경사. `outside` = 노선 밖으로 버린 고침 수(측점 단위). `detail` 을 제자리에서 고침. + */ +export function routeCarryPatches(input: RouteCarryInput): { + patches: CrossSectionPatch[]; + outside: number; +} { + const { detail } = input; + const sections = detail.cross_sections; + const book = pipeBook(input.pipe_ids); + const policy = ( + detail.longitudinal?.profile_alignment as + { policy?: { station_interval_m?: number } } | undefined + )?.policy; + const interval = policy?.station_interval_m ?? inferStationInterval(sections); + const from = input.from_keys ?? undefined; + const to = stationKeysFrom(sections, interval, input.owners, book); + layPipeOptions(detail, input.pipes_after); + const states = input.states; + const keyed = carryKeyedStates((name) => (states[name] as Raw | undefined) ?? {}, from, to); + const newAt = (id: string | null | undefined): number | null => + input.pipes_after.find((pipe) => id != null && pipe.id === id)?.chainage_m ?? null; + const chain = carryChainageStates( + (name) => (states[name] as Record | undefined) ?? null, + from, + to, + { + keyAt: (key) => { + const id = key.startsWith("pipe:") ? key.slice(5) : null; + if (!id) return null; + return id.startsWith("pipe-") ? book.chainageOf(id) : newAt(id); + }, + movedAt: (chainageM) => + newAt(input.pipes_before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id), + }, + ); + const cells = (name: string): Cells => chain.states[name] ?? {}; + const read = (name: KeyedState) => readKeyedIn(keyed.states[name], to); + layDraftAdjusts(detail, cells); + layCarriedEdits(detail, cells, { + shoulder: byChainage(read("shoulderext")), + berm: byChainage(read("bermdepth")), + }); + const kindChanged = Object.keys(chain.states.kindreset ?? {}).map(Number); + const savedAt = sections.map((section) => section.chainage_m); + const base = crossPatchesFromCache(detail, kindChanged, savedAt); + // 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 옛 `sessionOverlay` 와 같은 칸(445 H5) + const overlay: CrossSectionPatch[] = []; + byChainage(read("crossdesign")).forEach((choice, chainage) => { + const patch: Record = { + chainage_m: chainage, + ground_type: choice.ground_type, + section_mode: choice.section_mode, + }; + if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side; + if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type; + if (choice.paved !== undefined) patch.paved = choice.paved; + if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope; + if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice; + overlay.push(patch as unknown as CrossSectionPatch); + }); + for (const [chainage, offset] of read("rockb")) + if (typeof offset === "number") + overlay.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset }); + for (const [chainage, ratio] of read("cutslope")) + overlay.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio }); + const patches = mergeCrossPatches( + base, + overlay.filter((patch) => Number.isFinite(patch.chainage_m)), + ); + return { patches, outside: keyed.outside.size + chain.outside.size }; +} 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_Adjusts.ts b/B06_Section/B06_Section_Section_Adjusts.ts index 146aeb3dd..7675413be 100644 --- a/B06_Section/B06_Section_Section_Adjusts.ts +++ b/B06_Section/B06_Section_Section_Adjusts.ts @@ -13,93 +13,25 @@ * ========================================================================== */ import { readState } from "../A00_Common/b_page_state"; -import type { CrossDesign, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; -import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; -import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom"; -import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; -import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; -import { applySpanPatch, WALL_MIN_PURE_HEIGHT_M } from "./logic/B06_Section_UI_Cross_Culvert_Const"; -import { tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire"; -import { computeStoredLayouts } from "./B06_Section_Structure_Layouts"; +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { readBermDepths, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; +import { + layCarriedEdits, + layDraftAdjusts, + layPipeOptions, + type Cells, + type OptionPipe, +} from "./B06_Section_Route_Carry_Patch"; -type Cells = Record; -type Obj = Record; -const INLET = ["auto", "revet", "I", "L", "U"]; -const isObj = (value: unknown): value is Obj => !!value && typeof value === "object"; -const count = (value: unknown): number => - Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0; - -/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */ -function wallAdjust(value: unknown): WallAdjust | null { - if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value }; - if (!isObj(value)) return null; - const partial = value as Partial; - return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null }; -} +// 얹기 몸통 = `B06_Section_Route_Carry_Patch` 한 벌(서버 노선 바뀐 [저장] 넘김도 · 472-6) — 여기는 세션 겉. +export { layPipeOptions, type OptionPipe }; export function withDraftAdjusts( detail: SectionDetailResponse, projectId: string, routeId: number, ): void { - const read = (name: Parameters[0]): Cells => - readState(name, projectId, routeId) ?? {}; - const revet = read("revetx"); - const basin = read("basinadjust"); - const inlet = read("inletstruct"); - const extra = read("extrawall"); - const spans = read("extraspan"); - const ford = read("fordadjust"); - const box = read("boxadjust"); - const link = read("revetlink") as { detached?: Cells; grade?: Cells }; - for (const section of detail.cross_sections) { - const design = section.design as (CrossDesign & Obj) | null | undefined; - if (!design) continue; - const at = section.chainage_m.toFixed(2); - const own = (cells: Cells): Array<[string, unknown]> => - Object.entries(cells) - .filter(([key]) => key.startsWith(`${at}:`)) - .map(([key, value]) => [key.slice(at.length + 1), value]); - for (const [role, value] of own(revet)) { - const adjust = wallAdjust(value); - if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust }; - else if (value === null && design.revet_adjust?.[role]) { - // null = 되돌림 — 저장분 그 벽을 뺌(445 M2) - const { [role]: _gone, ...rest } = design.revet_adjust; - design.revet_adjust = rest; - } - } - if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string)) - design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"]; - if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] }; - // 세션에 있는 쪽만 — 없는 쪽은 저장분 그대로(445 H3) - const saved = design.extra_wall_counts; - if (at in extra || `b${at}` in extra) - design.extra_wall_counts = { - outlet: at in extra ? count(extra[at]) : (saved?.outlet ?? 0), - basin: `b${at}` in extra ? count(extra[`b${at}`]) : (saved?.basin ?? 0), - }; - for (const [wall, value] of own(spans)) { - const span = value as { lengthM: number; beforeM: number; afterM: number } | null; - if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue; - design.extra_spans = { - ...(design.extra_spans ?? {}), - [wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM }, - }; - } - if (isObj(link.detached) && at in link.detached) - design.revet_link_detached = !!link.detached[at]; - if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at]; - if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]); - const sides = box[at] as { left?: Obj; right?: Obj } | undefined; - if (isObj(sides)) - design.box_adjust = { - left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left }, - right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right }, - }; - } + layDraftAdjusts(detail, (name) => readState(name as never, projectId, routeId) ?? {}); } /** 노선 저장 뒤 새 노선으로 옮긴 세션 고침(417) — 사면 쉬프트 · 소단 깊이 · 측점 폭 · 메모 · 상세 표를 설계 칸에 얹음 · @@ -109,29 +41,10 @@ export function withCarriedEdits( projectId: string, routeId: number, ): void { - const read = (name: Parameters[0]): Cells => - readState(name, projectId, routeId) ?? {}; - const cells: Array<[string, Cells]> = [ - ["display_half_width_m", read("crossw")], - ["structure_note", read("structure-notes")], - ["structure_note_cad", read("structure-note-cads")], - ["detail_table", read("detail-table")], - ]; - const sides: Array<[string, ReturnType]> = [ - ["shoulder_extend", readShoulderExtends(projectId, routeId)], - ["berm_depth", readBermDepths(projectId, routeId)], - ]; - for (const section of detail.cross_sections) { - const design = section.design as (CrossDesign & Obj) | null | undefined; - if (!design) continue; - const at = section.chainage_m.toFixed(2); - for (const [field, values] of cells) if (values[at] !== undefined) design[field] = values[at]; - for (const [field, values] of sides) { - const own = values.get(Number(at)); - for (const side of ["left", "right"] as const) - if (own?.[side] !== undefined) design[`${field}_${side}_m`] = own[side]; - } - } + layCarriedEdits(detail, (name) => readState(name as never, projectId, routeId) ?? {}, { + shoulder: readShoulderExtends(projectId, routeId), + berm: readBermDepths(projectId, routeId), + }); } /** 관 옵션 조정값 몫 얹기(387-4b) — 관 초안 ?? 저장 관(페이지 자료 줄). 가게 · 자료 줄은 부를 때 늦게 불러옴 — @@ -158,96 +71,3 @@ export async function withPipeOptions( .catch(() => null); if (pipes) layPipeOptions(detail, pipes as OptionPipe[]); } - -/** 관 하나 — 자리 · 폼 칸(관 초안 · 저장 관 `pipe_points` 같은 꼴). */ -export interface OptionPipe { - chainage_m: number; - options?: Record | null; -} - -const isDefault = (a: WallAdjust): boolean => - Math.abs(a.x) < 1e-9 && a.d == null && a.h == null && a.m == null; -const round1 = (value: number): number => Math.round(value * 10) / 10; - -/** 조정창 `update` 와 같은 꼴 — x · d 는 그대로 · h · m 만(폼 한 칸이 주인 · 빈 칸 = 자동 null). */ -function setWall(design: CrossDesign, role: string, h: number | null, m: string | null): void { - const current = { ...ZERO_ADJUST, ...(design.revet_adjust?.[role] ?? {}) } as WallAdjust; - const next: WallAdjust = { ...current, h: h === null ? null : round1(h), m }; - const bucket = { ...(design.revet_adjust ?? {}) }; - if (isDefault(next)) delete bucket[role]; - else bucket[role] = next; - design.revet_adjust = bucket; -} - -/** 관 옵션의 조정값 몫을 상세에 얹음(폼 칸 전부 = 되보내기와 같은 규칙) — 같은 값이면 그대로. */ -export function layPipeOptions( - detail: SectionDetailResponse, - pipes: ReadonlyArray, -): void { - const sections = detail.cross_sections; - for (const pipe of pipes) { - const options = pipe.options; - if (!options) continue; - const near = (at: number): boolean => Math.abs(at - pipe.chainage_m) < PIPE_MATCH_M; - // 물넘이포장 = 제원만(66-2) · 벽 없음 - if (sections.some((s) => s.ford_pavement && near(s.ford_pavement.chainage_m ?? s.chainage_m))) - continue; - const owner = sections.find((s) => near(s.chainage_m)); - const culvert = owner?.culvert; - const design = owner?.design; - if (!owner || !culvert || !design) continue; - const num = (key: string): number | undefined => { - const value = Number(options[key]); - return Number.isFinite(value) ? value : undefined; - }; - for (const role of ["inlet", "outlet"] as const) { - const form = options[`${role}_revet_form`]; - setWall( - design, - role, - num(`${role}_revet_height_m`) ?? null, - typeof form === "string" ? form : null, - ); - } - if (culvert.hidden_pipe && !owner.ford && !owner.box) continue; - // 흙막이 n 단 = 지형이 세운 유출 다단(`pipeExtraCount` 와 같은 셈) - const outlet = design.extra_wall_counts?.outlet ?? 0; - const probe = { - ...owner, - design: { ...design, extra_wall_counts: { basin: 0, ...design.extra_wall_counts, outlet } }, - }; - const tiers = outlet - ? (computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet) - : 0; - for (let n = 1; n <= tiers; n++) { - const role = `extra${n - 1}`; - const value = (item: string): number | undefined => { - const raw = options[`soil_add_${n}_${item}`]; - return raw === undefined || raw === "" || !Number.isFinite(Number(raw)) - ? undefined - : Number(raw); - }; - const form = options[`soil_add_${n}_form`]; - const height = value("height"); - setWall( - design, - role, - height === undefined ? null : Number(Math.max(height, WALL_MIN_PURE_HEIGHT_M).toFixed(1)), - typeof form === "string" && form ? form : null, - ); - const span = { lengthM: value("length"), beforeM: value("before"), afterM: value("after") }; - if (Object.values(span).every((v) => v === undefined)) continue; - const at = tierSpanOf(owner, role); - const current = { - lengthM: Math.round((at.beforeM + at.afterM) * 10) / 10, - beforeM: Math.round(at.beforeM * 10) / 10, - afterM: Math.round(at.afterM * 10) / 10, - }; - const next = applySpanPatch(current, span); - design.extra_spans = { - ...(design.extra_spans ?? {}), - [role]: { length_m: next.lengthM, before_m: next.beforeM, after_m: next.afterM }, - }; - } - } -} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 1a3befedf..e976067f2 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -18,7 +18,6 @@ * ========================================================================== */ import { clearState, readState, readStateRaw, writeState } from "../A00_Common/b_page_state"; -import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station"; import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations"; import { @@ -29,11 +28,14 @@ import { } from "./B06_Section_Section_Drafts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import { withDraftAdjusts, withPipeOptions } from "./B06_Section_Section_Adjusts"; +import { crossPatchesFromCache } from "./B06_Section_Route_Carry_Patch"; + +// [저장] patch 바탕 = 한 벌(서버 노선 바뀐 [저장] 넘김도 · 472-6 6-B) +export { crossPatchesFromCache }; import type { CrossSection, CrossSectionPatch, SectionDetailResponse, - StandardCrossSection, } from "./B06_Section_Api_Fetch"; import { forgetCanon, @@ -45,9 +47,9 @@ import { savedDesigns, sessionDetail, } from "./B06_Section_Section_Canon"; -import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; +import { fetchSectionDetail } from "./B06_Section_Api_Fetch"; import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; -import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options"; +import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options"; import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types"; import type { DesignStoreValues } from "../A00_Common/design/design_store"; import { useDesignStore } from "../A00_Common/design/design_store"; @@ -60,16 +62,8 @@ import { type Keyed, type KeyedState, } from "./B06_Section_Station_Keys"; -import { - ROW_CARRY_KEYS, - sameStationList, - swapCrossSections, -} from "./logic/B06_Section_Station_Swap"; +import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap"; import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches"; -import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; -import { readCutSlopeSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; -import { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel"; -import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { emitSwap, type DetailListener } from "./B06_Section_Swap_Bus"; export { onAnySwap } from "./B06_Section_Swap_Bus"; @@ -466,53 +460,6 @@ export function peekSectionDetail( return routeId === null ? null : (cache.get(keyOf(projectId, routeId)) ?? null); } -/** - * 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름). - * - * 초기 계산값은 원복용으로 서버에 그대로 있고, 사용자가 만진 값은 이 캐시 한 세트가 - * 최종본이다. B05 [임시저장]도 이 세트를 함께 보내야 B06에서 만진 구조물 조정이 - * 영구저장소에 남는다 — 안 보내면 캐시만 비워져 편집이 사라진다. - * - * 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)를 측점마다 **통째로** 싣는다 — [저장] patch 의 바탕 - * (445 H3 · 세션 고침은 그 위에 `mergeCrossPatches`). `savedAt`(서버 행 누가거리)을 주면 그 자리 행이 없는 - * 측점(옮긴 관 · 구조물 · 새 측점 = 서버가 기본 설계로 새로 세움)은 지반 등 입력(`ROW_CARRY_KEYS`)도 실음(445 H2). - */ -export function crossPatchesFromCache( - detail: SectionDetailResponse, - kindChanged: readonly number[] = [], - savedAt?: readonly number[], -): CrossSectionPatch[] { - const patches: CrossSectionPatch[] = []; - // 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값은 서버가 비움 · 캐시에 남은 옛 값을 다시 안 실음 - const structureKeys: readonly string[] = STRUCTURE_ADJUST_KEYS; - const reset = (at: number) => kindChanged.some((c) => Math.abs(c - at) < PIPE_MATCH_M); - const rowless = (at: number) => !!savedAt && savedAt.every((c) => Math.abs(c - at) >= 0.01); - for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true }); - for (const section of detail.cross_sections) { - const design = section.design; - if (!design) continue; - const patch: CrossSectionPatch = { chainage_m: section.chainage_m }; - const skip = reset(section.chainage_m); - let touched = false; - // 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고 - // 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이 - // 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). - const source = design as unknown as Record; - const target = patch as unknown as Record; - const keys = rowless(section.chainage_m) - ? [...USER_TOUCHED_KEYS, ...ROW_CARRY_KEYS] - : USER_TOUCHED_KEYS; - for (const key of keys) { - const value = source[key]; - if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue; - target[key] = value; - touched = true; - } - if (touched) patches.push(patch); - } - return patches; -} - /** * [저장] · [확정] 횡단 patch(445) — 바탕 = 캐시 설계의 사용자 칸(측점마다 통째 · 서버 행이 없는 측점은 지반 등 * 입력까지) 위에 세션 고침(`overlay` · 벽 · 단 dict 는 열쇠마다 합침 · 안쪽 null = 되돌림). 캐시가 없으면 세션 고침만. @@ -529,47 +476,3 @@ export function cachedCrossPatches( : []; return mergeCrossPatches(base, overlay); } - -/** 노선 저장 뒤 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 횡단 보기 [저장] 본문과 같은 칸(445 H5). */ -function sessionOverlay(projectId: string, routeId: number): CrossSectionPatch[] { - const out: CrossSectionPatch[] = []; - crossDesignChoices(projectId, routeId).forEach((choice, chainage) => { - const patch: CrossSectionPatch = { - chainage_m: chainage, - ground_type: choice.ground_type, - section_mode: choice.section_mode, - }; - if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side; - if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type; - if (choice.paved !== undefined) patch.paved = choice.paved; - if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope; - if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice; - out.push(patch); - }); - const rock = readRockBoundarySession(projectId, routeId) ?? {}; - for (const [chainage, offset] of Object.entries(rock)) - if (typeof offset === "number") - out.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset }); - for (const [chainage, ratio] of Object.entries(readCutSlopeSession(projectId, routeId))) - out.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio }); - return out.filter((patch) => Number.isFinite(patch.chainage_m)); -} - -/** - * 노선 저장 뒤 고침을 영구저장소에 남긴다 — 횡단 보기 [저장]과 같은 본문(캐시 사용자 칸 · 카드 선택 · 암선 · - * 암 경사 · 표준 횡단면 · 풍선 자리 · 445 H5). 수정분이 없어도 한 번 보냄 — 서버가 지금 종단으로 횡단을 다시 셈 - * (429 ④ · 노선 뒤에 나간 계획고 초안). 저장 실패는 호출자가 처리한다. - */ -export async function saveCachedCrossPatches(projectId: string, routeId: number): Promise { - 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_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 5ab6fa521..333112dd5 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -22,76 +22,33 @@ import { renameChainageKeys } from "./logic/B06_Section_Station_Swap"; import { afterAnySwap } from "./B06_Section_Swap_Bus"; import { pipeIdAt, pipeIdNear } from "../A00_Common/design/design_pipe_ids"; import { showToast } from "@ui/ui_template_elements"; -import { - chainageKey, - curveMoves, - dedupeStationKeys, - looseChainage, - stationKey, - type StationKeyRole, -} from "@util/common_util_station_key"; +import { chainageKey, looseChainage, type StationKeyRole } from "@util/common_util_station_key"; import { inferStationInterval } from "@util/common_util_svg"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { + carryChainageStates as carryChainageCells, + carryKeyedStates, + chainageIn, + HINT, + keyIn, + KEYED_STATES, + NEAR_M, + nearestKeyed, + rawEntries, + ROUTE_CARRY_STATES, + stationKeysFrom, + type CarryFacilities, + type Keyed, + type KeyedState, + type Owner, + type Raw, +} from "./logic/B06_Section_Route_Carry"; -/** 측점 열쇠로 담는 세션 칸. */ -export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope"; -export const KEYED_STATES: readonly KeyedState[] = [ - "crossdesign", - "shoulderext", - "bermdepth", - "rockb", - "cutslope", -]; - -/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */ -const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const; -/** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */ -const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope", "shoulderext", "bermdepth"]; - -/** - * 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸. - * ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS`(측점 열쇠마다) + `STRUCTURE_ADJUST_KEYS`(관 · 구조물 열쇠만) · - * 거울 시험 `resources/tester/test_417_route_carry_mirror.py`. - */ -export const ROUTE_CARRY_STATES = { - revetx: ["revet_adjust"], - basinadjust: ["basin_adjust"], - inletstruct: ["inlet_structure"], - extrawall: ["extra_wall_counts"], - extraspan: ["extra_spans"], - revetlink: ["revet_link_detached", "revet_follow_grade"], - fordadjust: ["ford_adjust"], - boxadjust: ["box_adjust"], - crossw: ["display_half_width_m"], - "structure-notes": ["structure_note"], - "structure-note-cads": ["structure_note_cad"], - "detail-table": ["detail_table"], - shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"], - bermdepth: ["berm_depth_left_m", "berm_depth_right_m"], - uphill: [], // 상단측(측구 방향) — 프로젝트 칸 · 설계 칸 없음 · 노선 바뀐 저장 때 새 측점으로(443-2) -} as const; -/** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */ -const STRUCTURE_STATES = new Set([ - "revetx", - "basinadjust", - "inletstruct", - "extrawall", - "extraspan", - "revetlink", - "fordadjust", - "boxadjust", -]); - -/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */ -const NEAR_M = 0.01; -const HINT = "@at"; - -export type Keyed = { key: string; chainageM: number }; -type Raw = Record & { [HINT]?: Record }; +// 넘김 규칙 · 칸 목록 = logic 한 벌(서버 Node 입구도 · 472-6 6-B) — 부르는 쪽은 여기서 그대로 받음 +export { KEYED_STATES, ROUTE_CARRY_STATES, type CarryFacilities, type Keyed, type KeyedState }; const lists = new Map(); /** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */ -type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole }; const owners = new Map(); let current: { projectId: string; routeId: number } | null = null; @@ -145,39 +102,17 @@ export function useStructureIds( owners.set(projectId, known); } -const looksLikePipe = (section: CrossSection): boolean => - Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); - -/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이 - * 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */ +/** 측점 목록 → 열쇠(규칙 = `stationKeysFrom` · 관 id = 관 세션 장부). */ export function stationKeysOf( projectId: string | null, sections: ReadonlyArray, intervalM: number | null, ): Keyed[] { const known = projectId ? (owners.get(projectId) ?? []) : []; - const planned = known.some((owner) => owner.kind === "pipe"); - const keys = sections.map((section, index) => { - const at = section.chainage_m; - let key: string; - if (index === 0) key = stationKey("bp", at, intervalM); - else if (index === sections.length - 1) key = stationKey("ep", at, intervalM); - else if (section.kind === "irregular") { - const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M); - if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role); - else if (looksLikePipe(section)) { - // 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E) - const id = planned ? pipeIdNear(at, projectId) : pipeIdAt(at, projectId); - key = id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM); - } else - key = section.grid - ? stationKey("regular", at, intervalM) - : stationKey("irregular", at, intervalM); - } else key = stationKey(section.kind, at, intervalM); - return key; + return stationKeysFrom(sections, intervalM, known, { + near: (at) => pipeIdNear(at, projectId), + at: (at) => pipeIdAt(at, projectId), }); - const chainages = sections.map((section) => section.chainage_m); - return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i] })); } type KeyedDetail = Pick; @@ -229,22 +164,12 @@ export function registeredKeys( ): string[] { const list = lists.get(scopeOf(projectId, routeId)); const computed = stationKeysOf(projectId, sections, intervalM); - return sections.map((s, i) => (list && nearest(list, s.chainage_m)?.key) ?? computed[i].key); -} - -function nearest(list: Keyed[], chainageM: number): Keyed | undefined { - let best: Keyed | undefined; - for (const entry of list) { - const gap = Math.abs(entry.chainageM - chainageM); - if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry; - } - return best; + return sections.map((s, i) => (list && nearestKeyed(list, s.chainage_m)?.key) ?? computed[i].key); } /** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */ export function keyAt(projectId: string, routeId: number, chainageM: number): string { - const list = lists.get(scopeOf(projectId, routeId)); - return (list && nearest(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`; + return keyIn(lists.get(scopeOf(projectId, routeId)), chainageM); } /** @@ -257,21 +182,7 @@ export function chainageOfKey( key: string, hint?: number, ): number | null { - const list = lists.get(scopeOf(projectId, routeId)); - const hit = list?.find((entry) => entry.key === key); - if (hit) return Number(chainageKey(hit.chainageM)); - const at = looseChainage(key) ?? hint; - if (at === undefined) return null; - if (!list) return Number(chainageKey(at)); - const near = nearest(list, at); - return near ? Number(chainageKey(near.chainageM)) : null; -} - -/** 두 노선 목록 사이 MC 가 움직인 곡선 열쇠(432-3 · 서버 `carry_cross_inputs` 와 같음). */ -function curveMovesBetween(projectId: string, fromRouteId: number, toRouteId: number) { - const pairs = (routeId: number) => - (lists.get(scopeOf(projectId, routeId)) ?? []).map((e) => [e.key, e.chainageM] as const); - return curveMoves(pairs(fromRouteId), pairs(toRouteId)); + return chainageIn(lists.get(scopeOf(projectId, routeId)), key, hint); } function readRaw(name: KeyedState, projectId: string, routeId: number): Raw { @@ -279,13 +190,6 @@ function readRaw(name: KeyedState, projectId: string, routeId: number): Raw { return raw && typeof raw === "object" ? raw : {}; } -function entries(raw: Raw): Array<[string, V, number | undefined]> { - const hints = raw[HINT] ?? {}; - return Object.entries(raw) - .filter(([key]) => key !== HINT) - .map(([key, value]) => [key, value as V, hints[key]]); -} - /** * 칸 하나를 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌(지우지 않음). * 옛 열쇠(누가거리 · at:)는 지금 측점 열쇠로 옮겨 다시 씀(목록이 있을 때). @@ -294,7 +198,7 @@ export function readKeyed(name: KeyedState, projectId: string, routeId: numbe const raw = readRaw(name, projectId, routeId); const out = new Map(); let moved = false; - for (const [key, value, hint] of entries(raw)) { + for (const [key, value, hint] of rawEntries(raw)) { const at = chainageOfKey(projectId, routeId, key, hint); if (at === null) continue; out.set(chainageKey(at), value); @@ -319,7 +223,7 @@ export function writeKeyed( const raw = readRaw(name, projectId, routeId); const next: Raw = { [HINT]: {} }; const replaced = new Set((only ?? [...byChainage.keys()]).map((at) => chainageKey(Number(at)))); - for (const [key, value, hint] of entries(raw)) { + for (const [key, value, hint] of rawEntries(raw)) { const at = chainageOfKey(projectId, routeId, key, hint); if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue; next[key] = value; @@ -346,30 +250,12 @@ export function carryStationDrafts( toRouteId: number, { quiet = false }: { quiet?: boolean } = {}, ): number { - const outside = new Set(); - const curves = curveMovesBetween(projectId, fromRouteId, toRouteId); - for (const name of KEYED_STATES) { - const raw = readRaw(name, projectId, fromRouteId); - const next: Raw = { [HINT]: {} }; - for (const [key, value] of entries(raw)) { - // 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로 - const fromAt = - looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key); - const was = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt); - const stable = curves.get(was) ?? was; - const toAt = chainageOfKey(projectId, toRouteId, stable, looseChainage(stable) ?? undefined); - const carried = carriedValue(name, value); - if (toAt === null || stable.startsWith("at:")) { - if (carried !== null) outside.add(stable); - continue; - } - if (carried === null) continue; - const toKey = keyAt(projectId, toRouteId, toAt); - next[toKey] = carried; - next[HINT]![toKey] = toAt; - } - writeState(name, next, projectId, toRouteId); - } + const { states, outside } = carryKeyedStates( + (name) => readRaw(name, projectId, fromRouteId), + lists.get(scopeOf(projectId, fromRouteId)), + lists.get(scopeOf(projectId, toRouteId)), + ); + for (const name of KEYED_STATES) writeState(name, states[name], projectId, toRouteId); if (outside.size && !quiet) showToast(`노선 밖 고침 ${outside.size} 건 — 사라진 측점이라 버렸습니다.`, "info"); return outside.size; @@ -394,28 +280,6 @@ export function carryStationKeys(projectId: string, routeId: number, next: Keyed return outside; } -/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */ -function carriedValue(name: KeyedState, value: unknown): unknown { - if (CARRY_WHOLE.includes(name)) return value; - if (name !== "crossdesign" || !value || typeof value !== "object") return null; - const source = value as Record; - const picked = Object.fromEntries( - CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]), - ); - return Object.keys(picked).length ? picked : null; -} - -/** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */ -export interface CarryFacilities { - /** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */ - movedAt?: (chainageM: number) => number | null | undefined; - /** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */ - keyAt?: (key: string) => number | null | undefined; -} - -/** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */ -const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/; - /** * 노선 저장 뒤(새 노선 측점 열쇠를 세운 뒤) 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고)을 옛 노선 * → 새 노선 측점으로 옮김 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 · @@ -429,62 +293,42 @@ export function carryChainageStates( toRouteId: number, facilities: CarryFacilities = {}, ): number { - const facilityAt = facilities.keyAt ?? (() => null); - const outside = new Set(); - const curves = curveMovesBetween(projectId, fromRouteId, toRouteId); - const moveAt = (state: string, text: string): string | null => { - const moved = facilities.movedAt?.(Number(text)); - if (moved !== null && moved !== undefined) return chainageKey(moved); - const was = keyAt(projectId, fromRouteId, Number(text)); - const stable = curves.get(was) ?? was; - const structural = stable.startsWith("pipe:") || stable.startsWith("structure:"); - // 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점) - const toAt = - stable.startsWith("at:") || (STRUCTURE_STATES.has(state) && !structural) - ? null - : chainageOfKey( - projectId, - toRouteId, - stable, - (structural ? facilityAt(stable) : null) ?? Number(text), - ); - if (toAt === null) outside.add(stable); - return toAt === null ? null : chainageKey(toAt); - }; - const rekey = (state: string, cells: Record): Record => { - const next: Record = {}; - for (const [key, value] of Object.entries(cells)) { - const head = CHAINAGE_HEAD.exec(key); - const to = head && moveAt(state, head[2]); - if (head && to !== null) next[`${head[1]}${to}${head[3] ?? ""}`] = value; - } - return next; - }; - for (const state of Object.keys(ROUTE_CARRY_STATES)) { - if ((KEYED_STATES as readonly string[]).includes(state)) continue; // 측점 열쇠 칸 = carryStationDrafts - const name = state as StateName; - const raw = readState>(name, projectId, fromRouteId); - if (!raw || typeof raw !== "object") continue; - // 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹 - const next = - state === "revetlink" - ? Object.fromEntries( - Object.entries(raw).map(([part, cells]) => [ - part, - cells && typeof cells === "object" - ? rekey(state, cells as Record) - : cells, - ]), - ) - : rekey(state, raw); - writeState(name, next, projectId, toRouteId); - } - // 관 종류 바꿈 자리(318-4 · 프로젝트 칸) — 구조물 조절과 같은 규칙으로 옮김(445 M7) - const resets = readState>("kindreset", projectId); - if (resets) writeState("kindreset", rekey("revetx", resets), projectId); + const { states, outside } = carryChainageCells( + (name) => + name === "kindreset" + ? readState>("kindreset", projectId) + : readState>(name as StateName, projectId, fromRouteId), + lists.get(scopeOf(projectId, fromRouteId)), + lists.get(scopeOf(projectId, toRouteId)), + facilities, + ); + for (const [name, next] of Object.entries(states)) + if (name === "kindreset") writeState("kindreset", next, projectId); + else writeState(name as StateName, next, projectId, toRouteId); return outside.size; } +/** 노선 바뀐 [저장] 본문의 넘김 입력(472-6 6-B) — 서버 Node `stations.carry` 가 같은 규칙으로 새 노선에 얹음. + * 칸 = 측점 열쇠 칸 다섯 · 누가거리 칸(상단측 뺌 · 요구 274) · 관 종류 바꿈 자리 · 옛 노선 열쇠 목록 · 측점 주인 · + * 관 세션 장부. */ +export function routeCarryInputs(projectId: string, fromRouteId: number): Record { + const states: Record = {}; + for (const name of KEYED_STATES) states[name] = readRaw(name, projectId, fromRouteId); + for (const state of Object.keys(ROUTE_CARRY_STATES)) { + if (state === "uphill" || (KEYED_STATES as readonly string[]).includes(state)) continue; + const raw = readState>(state as StateName, projectId, fromRouteId); + if (raw && typeof raw === "object") states[state] = raw; + } + const resets = readState>("kindreset", projectId); + if (resets) states.kindreset = resets; + return { + states, + from_keys: lists.get(scopeOf(projectId, fromRouteId)) ?? null, + owners: owners.get(projectId) ?? [], + pipe_ids: readState("pipe-ids", projectId) ?? null, + }; +} + /** * 측점이 움직임(갈아끼움 · 옛 0.01 m 글 → 새 글) — 조정창 맵이 안 선 누가거리 칸(B05 만 연 페이지 · 새로고침 · * 세월 · BOX · 연동)을 세션에서 바로 새 자리로(415-3). 칸 = `ROUTE_CARRY_STATES`(측점 열쇠 칸 빼고) · 조정창 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/B06_Section/logic/B06_Section_Design_Keys.ts b/B06_Section/logic/B06_Section_Design_Keys.ts new file mode 100644 index 000000000..7a2d142f8 --- /dev/null +++ b/B06_Section/logic/B06_Section_Design_Keys.ts @@ -0,0 +1,55 @@ +/* ============================================================================= + * B06_Section_Design_Keys.ts + * 횡단 설계 칸 목록 한 벌 — 사용자 값(`USER_TOUCHED_KEYS`) · 구조물 조정값(`STRUCTURE_ADJUST_KEYS`). + * 자료만(브라우저 · 서버 Node 입구 `stations.carry` 가 같은 목록 · 472-6 6-B) · 서버 짝 = 각 목록 주석. + * ========================================================================== */ + +/** + * 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로 + * 실어 보낸다(`B06_Section_Section_Store` · `B06_Section_Route_Carry_Patch`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS` + * 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`). + * + * 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을 + * 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). + */ +export const USER_TOUCHED_KEYS = [ + "display_half_width_m", + "inlet_structure", + "basin_adjust", + "revet_adjust", + "ford_adjust", + "box_adjust", + "extra_wall_counts", + "extra_spans", + "revet_link_detached", + "revet_follow_grade", + // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). + "berm", + // 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다. + "cut_slope_ratio_user", + // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. + "structure_note", + // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. + "structure_note_cad", + // [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) + "detail_table", + // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 + "shoulder_extend_left_m", + "shoulder_extend_right_m", + // 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김 + "berm_depth_left_m", + "berm_depth_right_m", +] as const; + +/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */ +export const STRUCTURE_ADJUST_KEYS = [ + "inlet_structure", + "basin_adjust", + "revet_adjust", + "ford_adjust", + "box_adjust", + "extra_wall_counts", + "extra_spans", + "revet_link_detached", + "revet_follow_grade", +] as const; diff --git a/B06_Section/logic/B06_Section_Route_Carry.ts b/B06_Section/logic/B06_Section_Route_Carry.ts new file mode 100644 index 000000000..9b72ead2e --- /dev/null +++ b/B06_Section/logic/B06_Section_Route_Carry.ts @@ -0,0 +1,300 @@ +/* ============================================================================= + * B06_Section_Route_Carry.ts + * 노선이 바뀔 때 횡단 고침 넘김 규칙 **한 벌**(PLAN 344-3 · 417 · 472-6 6-B) — 순수 계산(세션 · 화면 없음). + * + * · 측점 열쇠 목록 = `stationKeysFrom`(일반 grid:k · 관 pipe: · 구조물 structure: · 시종점 end:BP/EP · + * id 모름 at:<0.01 m> · 규칙 = `common_util_station_key`). + * · 측점 열쇠 칸 다섯 = `carryKeyedStates` — 넘김 = 카드 선택 넷 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이 · + * 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점 고침은 버림(수만 셈). + * · 누가거리 열쇠 칸(`ROUTE_CARRY_STATES`) = `carryChainageStates` — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리 · + * 구조물 조절 = 관 · 구조물 열쇠만. + * 쓰는 곳 = 브라우저 세션(`B06_Section_Station_Keys` · 끌기 동안 같은 노선 안 목록 갈림) · 서버 Node 입구 + * `stations.carry`(노선 바뀐 [저장] · `design_calc_node_carry.ts`). + * ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS` + `STRUCTURE_ADJUST_KEYS` · 거울 시험 `test_417_route_carry.py`. + * ========================================================================== */ + +import { + chainageKey, + curveMoves, + dedupeStationKeys, + looseChainage, + stationKey, + type StationKeyRole, +} from "../../common_util/common_util_station_key"; +import type { CrossSection } from "../B06_Section_Api_Fetch"; + +/** 측점 열쇠로 담는 세션 칸. */ +export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope"; +export const KEYED_STATES: readonly KeyedState[] = [ + "crossdesign", + "shoulderext", + "bermdepth", + "rockb", + "cutslope", +]; + +/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */ +const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const; +/** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */ +const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope", "shoulderext", "bermdepth"]; + +/** + * 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸. + * ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS`(측점 열쇠마다) + `STRUCTURE_ADJUST_KEYS`(관 · 구조물 열쇠만) · + * 거울 시험 `resources/tester/test_417_route_carry.py`. + */ +export const ROUTE_CARRY_STATES = { + revetx: ["revet_adjust"], + basinadjust: ["basin_adjust"], + inletstruct: ["inlet_structure"], + extrawall: ["extra_wall_counts"], + extraspan: ["extra_spans"], + revetlink: ["revet_link_detached", "revet_follow_grade"], + fordadjust: ["ford_adjust"], + boxadjust: ["box_adjust"], + crossw: ["display_half_width_m"], + "structure-notes": ["structure_note"], + "structure-note-cads": ["structure_note_cad"], + "detail-table": ["detail_table"], + shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"], + bermdepth: ["berm_depth_left_m", "berm_depth_right_m"], + uphill: [], // 상단측(측구 방향) — 프로젝트 칸 · 설계 칸 없음 · 노선 바뀐 [저장]은 안 보냄(요구 274) +} as const; +/** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */ +const STRUCTURE_STATES = new Set([ + "revetx", + "basinadjust", + "inletstruct", + "extrawall", + "extraspan", + "revetlink", + "fordadjust", + "boxadjust", +]); + +/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */ +export const NEAR_M = 0.01; +export const HINT = "@at"; + +export type Keyed = { key: string; chainageM: number }; +export type Raw = Record & { [HINT]?: Record }; +/** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */ +export type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole }; +/** 관 측점의 관 id — 관 장부(세션 `pipe-sN`) · 찾기만(`near`) · 없으면 지음(`at`). */ +export interface PipeIdBook { + near: (chainageM: number) => string | null; + at: (chainageM: number) => string; +} + +const looksLikePipe = (section: CrossSection): boolean => + Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); + +/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이 + * 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */ +export function stationKeysFrom( + sections: ReadonlyArray, + intervalM: number | null, + known: ReadonlyArray, + pipes: PipeIdBook | null, +): Keyed[] { + const planned = known.some((owner) => owner.kind === "pipe"); + const keys = sections.map((section, index) => { + const at = section.chainage_m; + if (index === 0) return stationKey("bp", at, intervalM); + if (index === sections.length - 1) return stationKey("ep", at, intervalM); + if (section.kind !== "irregular") return stationKey(section.kind, at, intervalM); + const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M); + if (owner) return stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role); + if (looksLikePipe(section)) { + // 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E) + const id = pipes ? (planned ? pipes.near(at) : pipes.at(at)) : null; + return id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM); + } + return section.grid + ? stationKey("regular", at, intervalM) + : stationKey("irregular", at, intervalM); + }); + const chainages = sections.map((section) => section.chainage_m); + return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i] })); +} + +/** 목록에서 그 자리(0.01 m 안) 가장 가까운 측점. */ +export function nearestKeyed(list: ReadonlyArray, chainageM: number): Keyed | undefined { + let best: Keyed | undefined; + for (const entry of list) { + const gap = Math.abs(entry.chainageM - chainageM); + if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry; + } + return best; +} + +/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */ +export function keyIn(list: ReadonlyArray | undefined, chainageM: number): string { + return (list && nearestKeyed(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`; +} + +/** + * 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금 + * 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖). + */ +export function chainageIn( + list: ReadonlyArray | undefined, + key: string, + hint?: number, +): number | null { + const hit = list?.find((entry) => entry.key === key); + if (hit) return Number(chainageKey(hit.chainageM)); + const at = looseChainage(key) ?? hint; + if (at === undefined) return null; + if (!list) return Number(chainageKey(at)); + const near = nearestKeyed(list, at); + return near ? Number(chainageKey(near.chainageM)) : null; +} + +/** 두 목록 사이 MC 가 움직인 곡선 열쇠(432-3 · 서버 `carry_cross_inputs` 와 같음). */ +export function curveMovesIn( + from: ReadonlyArray | undefined, + to: ReadonlyArray | undefined, +): Map { + const pairs = (list: ReadonlyArray | undefined) => + (list ?? []).map((e) => [e.key, e.chainageM] as const); + return curveMoves(pairs(from), pairs(to)); +} + +/** 칸 원본 → [열쇠, 값, 쓴 때 자리]. */ +export function rawEntries(raw: Raw): Array<[string, V, number | undefined]> { + const hints = raw[HINT] ?? {}; + return Object.entries(raw) + .filter(([key]) => key !== HINT) + .map(([key, value]) => [key, value as V, hints[key]]); +} + +/** 칸 원본을 목록 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌. */ +export function readKeyedIn(raw: Raw, list: ReadonlyArray | undefined): Map { + const out = new Map(); + for (const [key, value, hint] of rawEntries(raw)) { + const at = chainageIn(list, key, hint); + if (at !== null) out.set(chainageKey(at), value); + } + return out; +} + +/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */ +function carriedValue(name: KeyedState, value: unknown): unknown { + if (CARRY_WHOLE.includes(name)) return value; + if (name !== "crossdesign" || !value || typeof value !== "object") return null; + const source = value as Record; + const picked = Object.fromEntries( + CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]), + ); + return Object.keys(picked).length ? picked : null; +} + +/** + * 측점 열쇠 칸 다섯을 옛 목록 → 새 목록으로(넘김 규칙 = 머리 주석) — 칸마다 새 원본(빈 칸도) · 노선 밖으로 나간 + * 고침의 열쇠(측점 단위). + */ +export function carryKeyedStates( + read: (name: KeyedState) => Raw, + from: ReadonlyArray | undefined, + to: ReadonlyArray | undefined, +): { states: Record; outside: Set } { + const outside = new Set(); + const curves = curveMovesIn(from, to); + const states = {} as Record; + for (const name of KEYED_STATES) { + const next: Raw = { [HINT]: {} }; + for (const [key, value] of rawEntries(read(name))) { + // 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로 + const fromAt = looseChainage(key) === null ? null : chainageIn(from, key); + const was = fromAt === null ? key : keyIn(from, fromAt); + const stable = curves.get(was) ?? was; + const toAt = chainageIn(to, stable, looseChainage(stable) ?? undefined); + const carried = carriedValue(name, value); + if (toAt === null || stable.startsWith("at:")) { + if (carried !== null) outside.add(stable); + continue; + } + if (carried === null) continue; + const toKey = keyIn(to, toAt); + next[toKey] = carried; + next[HINT]![toKey] = toAt; + } + states[name] = next; + } + return { states, outside }; +} + +/** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */ +export interface CarryFacilities { + /** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */ + movedAt?: (chainageM: number) => number | null | undefined; + /** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */ + keyAt?: (key: string) => number | null | undefined; +} + +/** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */ +const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/; + +/** + * 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고 · 관 종류 바꿈 자리 `kindreset`)을 옛 목록 → 새 목록 + * 측점으로 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 · 못 옮긴 고침은 버림. + * 돌려줌 = 원본이 있던 칸만 새 원본 · 버린 고침의 열쇠(측점 단위). + * 시설 풀이(`facilities`) = 옛 저장 시설 측점 → 새 노선 그 시설 측점(좌표 짝 · 초안으로 안 따라간 고침) · 관 · + * 구조물 열쇠 → 새 누가거리(관 id 규칙이 갈림 — 세션 `pipe-s3` · 계산 줄 `pipe:p8017`). + */ +export function carryChainageStates( + read: (name: string) => Record | null, + from: ReadonlyArray | undefined, + to: ReadonlyArray | undefined, + facilities: CarryFacilities = {}, +): { states: Record>; outside: Set } { + const facilityAt = facilities.keyAt ?? (() => null); + const outside = new Set(); + const curves = curveMovesIn(from, to); + const moveAt = (state: string, text: string): string | null => { + const moved = facilities.movedAt?.(Number(text)); + if (moved !== null && moved !== undefined) return chainageKey(moved); + const was = keyIn(from, Number(text)); + const stable = curves.get(was) ?? was; + const structural = stable.startsWith("pipe:") || stable.startsWith("structure:"); + // 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점) + const toAt = + stable.startsWith("at:") || (STRUCTURE_STATES.has(state) && !structural) + ? null + : chainageIn(to, stable, (structural ? facilityAt(stable) : null) ?? Number(text)); + if (toAt === null) outside.add(stable); + return toAt === null ? null : chainageKey(toAt); + }; + const rekey = (state: string, cells: Record): Record => { + const next: Record = {}; + for (const [key, value] of Object.entries(cells)) { + const head = CHAINAGE_HEAD.exec(key); + const to = head && moveAt(state, head[2]); + if (head && to !== null) next[`${head[1]}${to}${head[3] ?? ""}`] = value; + } + return next; + }; + const states: Record> = {}; + for (const state of Object.keys(ROUTE_CARRY_STATES)) { + if ((KEYED_STATES as readonly string[]).includes(state)) continue; // 측점 열쇠 칸 = carryKeyedStates + const raw = read(state); + if (!raw || typeof raw !== "object") continue; + // 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹 + states[state] = + state === "revetlink" + ? Object.fromEntries( + Object.entries(raw).map(([part, cells]) => [ + part, + cells && typeof cells === "object" + ? rekey(state, cells as Record) + : cells, + ]), + ) + : rekey(state, raw); + } + // 관 종류 바꿈 자리(318-4 · 프로젝트 칸) — 구조물 조절과 같은 규칙으로 옮김(445 M7) + const resets = read("kindreset"); + if (resets) states.kindreset = rekey("revetx", resets); + return { states, outside }; +} 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_pipe_match.ts b/common_util/common_util_pipe_match.ts new file mode 100644 index 000000000..69ebbc33c --- /dev/null +++ b/common_util/common_util_pipe_match.ts @@ -0,0 +1,7 @@ +/* ============================================================================= + * common_util/common_util_pipe_match.ts + * 관 · 구조물 찾는 거리 한 값 — 화면(`design_selection` 이 내줌) · 서버 Node 묶음(노선 바뀐 [저장] 넘김)이 같이 씀. + * ========================================================================== */ + +/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */ +export const PIPE_MATCH_M = 0.51; 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 e60d42c14..352272882 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 a5ad65ad2..719b54db4 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:553`). +- 저장 중 = `saving` 표시 · 묶음 맞춤은 저장 뒤로 미룸(`design_pipeline_page.ts` saving 분기). diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index 3b9d004c8..9fe7fc23d 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -51,9 +51,8 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | 내용 | 자리(파일:줄) | 상태 | |---|---|---| | 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 | -| 노선 바뀐 [저장]만 9 차례 · 차례마다 따로 커밋 — 실패 = 앞 차례만 저장(노선 안 바뀐 저장 = 한 본문 · 전부 아니면 없음) | `design_save.ts:253-299` · `B06_Section_Router_DesignSave.py` | 472-6 6-B | | `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 | -| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠 | `design_save.ts:187-188` | 추정 | +| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠(누가거리 · 관이 따라 옮긴 새 자리와 안 맞으면 안 얹힘) | `design_save.ts:145` · `B06_Section_Router_DesignSave.py` `_patched_points` | 추정 · 옛 길과 같음 | | ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정 메모리 맵 · B05 상단측 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore | 관 · 구조물 = `test_472_5_shop_owner` · 나머지 추정 | | 스냅 켬 · 간격 = 되돌리기 밖 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` | 추정 | | `flushDesignStore` 부름 없음 — 멈춘 뒤 1 s 안 새로고침 = 가게 세션 사본 잃음(다시 셈) | `design_store.ts:179` | 코드 확인 | diff --git a/resources/tester/helper_152_5_pavement_pick.cjs b/resources/tester/helper_152_5_pavement_pick.cjs index 8c8429b1a..3752a6297 100644 --- a/resources/tester/helper_152_5_pavement_pick.cjs +++ b/resources/tester/helper_152_5_pavement_pick.cjs @@ -51,6 +51,7 @@ function loadTs(file, stubs = {}) { const surface = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Surface.ts")); const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: () => null, writeState: () => {} }, }); diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index e577756c2..fb032a084 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -27,6 +27,7 @@ function loadTs(file, stubs = {}) { // 세션(structure-pick) — 메모리 한 벌 const store = new Map(); const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 7a96b037f..b548502e1 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -27,6 +27,7 @@ global.requestAnimationFrame = (fn) => fn(); const store = new Map(); const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), diff --git a/resources/tester/helper_232_1_3d_press.cjs b/resources/tester/helper_232_1_3d_press.cjs index 190e3cbde..4d6083250 100644 --- a/resources/tester/helper_232_1_3d_press.cjs +++ b/resources/tester/helper_232_1_3d_press.cjs @@ -59,6 +59,7 @@ function fire(type, clientX, clientY) { /* ── 진짜 모듈 ── */ const store = new Map(); const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs index 29e6833fd..a6f6b058e 100644 --- a/resources/tester/helper_233_1_profile_pick.cjs +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -22,6 +22,7 @@ function loadTs(file, stubs = {}) { const store = new Map(); const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs index aacd677f8..a774d28c4 100644 --- a/resources/tester/helper_233_2_view_pick.cjs +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -62,6 +62,7 @@ globalThis.window = { addEventListener() {}, removeEventListener() {} }; const store = new Map(); const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), diff --git a/resources/tester/helper_233_3_release.cjs b/resources/tester/helper_233_3_release.cjs index 6166dfb3a..413e18288 100644 --- a/resources/tester/helper_233_3_release.cjs +++ b/resources/tester/helper_233_3_release.cjs @@ -39,6 +39,7 @@ const press = (key, { inInput = false, prevented = false } = {}) => { const store = new Map(); const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), diff --git a/resources/tester/helper_270_1_cross_map.cjs b/resources/tester/helper_270_1_cross_map.cjs index b73bc2b8f..f8814f58d 100644 --- a/resources/tester/helper_270_1_cross_map.cjs +++ b/resources/tester/helper_270_1_cross_map.cjs @@ -26,6 +26,7 @@ globalThis.document = { addEventListener() {}, removeEventListener() {} }; const store = new Map(); const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), diff --git a/resources/tester/helper_284_1_pill_pick.cjs b/resources/tester/helper_284_1_pill_pick.cjs index dc349389d..0bf09a76f 100644 --- a/resources/tester/helper_284_1_pill_pick.cjs +++ b/resources/tester/helper_284_1_pill_pick.cjs @@ -24,6 +24,7 @@ function loadTs(file, stubs = {}) { } const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: () => null, writeState() {} }, }); const PREFIX = "irregular:"; diff --git a/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index 63f81c0d6..72a1a3744 100644 --- a/resources/tester/helper_318_cross_card.cjs +++ b/resources/tester/helper_318_cross_card.cjs @@ -488,8 +488,10 @@ const out = {}; const load = makeLoader( [ B06("B06_Section_Api_Culvert_Options"), + B06("logic/B06_Section_Design_Keys"), // 칸 목록 한 벌(472-6) path.join(ROOT, "A00_Common", "b_page_state.ts"), path.join(ROOT, "A00_Common", "design", "design_selection.ts"), + path.join(ROOT, "common_util", "common_util_pipe_match.ts"), // 472-6 ], { "./B06_Section_Section_Drafts": { @@ -596,14 +598,22 @@ const loadDrafts = makeLoader( const loadStore = makeLoader( [ B06("B06_Section_Section_Store"), + B06("B06_Section_Route_Carry_Patch"), // patch 바탕 한 벌(472-6) B06("B06_Section_Api_Culvert_Options"), path.join(ROOT, "A00_Common", "b_page_state.ts"), path.join(ROOT, "A00_Common", "design", "design_selection.ts"), + path.join(ROOT, "common_util", "common_util_pipe_match.ts"), // 472-6 ], { "./B06_Section_Cross_Refresh": { USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"], }, + "./logic/B06_Section_Design_Keys": { + USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"], + STRUCTURE_ADJUST_KEYS: makeLoader([B06("logic/B06_Section_Design_Keys")])( + B06("logic/B06_Section_Design_Keys"), + ).STRUCTURE_ADJUST_KEYS, + }, }, ); diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs index 58c0a95a9..cbe3683a4 100644 --- a/resources/tester/helper_319_kind_swap.cjs +++ b/resources/tester/helper_319_kind_swap.cjs @@ -89,8 +89,10 @@ const part1 = (async () => { B05("B05_Profile_UI_Structures_Panel_Events"), B05("B05_Profile_Api_Structures"), B06("B06_Section_Api_Culvert_Options"), + B06("logic/B06_Section_Design_Keys"), // 칸 목록 · 찾는 거리 한 벌(472-6) path.join(ROOT, "A00_Common", "b_page_state.ts"), path.join(ROOT, "A00_Common", "design", "design_selection.ts"), + path.join(ROOT, "common_util", "common_util_pipe_match.ts"), path.join(ROOT, "common_util", "common_util_summary_item.ts"), ], { 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..c62dae2f8 100644 --- a/resources/tester/helper_346_3_route_save.cjs +++ b/resources/tester/helper_346_3_route_save.cjs @@ -36,34 +36,23 @@ const STUBS = { currentRoute: () => "b05-profile", remountRoute: () => calls.push("remount"), }, + "../A00_Common/design/design_pipeline_page": { + commitRouteFacilities: () => calls.push("commit"), // 저장 = 초안 전 관 · 구조물 버림(6-B) + }, "../B06_Section/B06_Section_Section_Store": { invalidateSectionDetail: (_p, routeId) => calls.push(`invalidate:${routeId ?? "all"}`), - loadSectionDetail: async (_p, routeId) => ( - calls.push(`load:${routeId}`), - { tag: routeId, cross_sections: [] } - ), - saveCachedCrossPatches: async (_p, routeId) => (calls.push(`crossSave:${routeId}`), 1), }, + // 6-B — 넘김은 서버(Node · 같은 TS) · 여기서는 옛 노선 세션 칸 원본만 냄 "../B06_Section/B06_Section_Station_Keys": { - useStationKeys: (_p, routeId, detail) => calls.push(`keys:${routeId}:${detail.tag}`), - carryStationDrafts: (_p, from, to, opts) => ( - calls.push(`carry:${from}->${to}${opts?.quiet ? ":quiet" : ""}`), - 2 - ), - // 417 — 누가거리 열쇠 조절 옮김 - carryChainageStates: (_p, from, to) => (calls.push(`carryAt:${from}->${to}`), 1), + routeCarryInputs: (_p, from) => (calls.push(`carryInputs:${from}`), { from_keys: [], from }), }, - "../B06_Section/B06_Section_Section_Adjusts": { - withDraftAdjusts: (_d, _p, routeId) => calls.push(`adjusts:${routeId}`), - withCarriedEdits: (_d, _p, routeId) => calls.push(`edits:${routeId}`), + "../B06_Section/B06_Section_Api_Fetch": { + sectionConfirmBody: (standard, patches, massHaul) => ({ standard, patches, massHaul }), }, - "../B04_PreProcess/B04_PreProcess_Api_Fetch": { - fetchDetailPipePoints: async () => (calls.push("pipes"), { pipe_points: [] }), - }, - "../A00_Common/design/design_pipe_ids": { pipeChainageOf: () => null }, + "../B06_Section/B06_Section_UI_Standard_Panel": { readStandardCrossSession: () => ({ std: 1 }) }, + "@util/common_util_mass_haul_balance_view": { balloonOffsetsPayload: () => ({ b: [1, 2] }) }, "./B05_Profile_Api_Fetch": { clearRouteLatestCache: () => calls.push("latest:clear"), - flushUphillOverrides: async () => calls.push("uphill"), // 옮긴 뒤 · 횡단 패치 앞(443-2) }, "./B05_Profile_Api_Replan": { replanRoute: async (_p, body) => { @@ -153,9 +142,17 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap out.keptAfterSave = kept; out.summary = apply.routeSaveSummary(saved, 2); - // 저장 뒤 횡단 초안 넘김 차례 — 새 상세 → 측점 열쇠 → 넘김 → 넘긴 고침 저장 + // 노선 바뀐 [저장] 횡단 조각(6-B) — 옛 노선 세션 칸 원본 + 표준 횡단 · 풍선 · 저장 뒤 상단측 · 종류 바꿈 비움 calls.length = 0; - out.outside = await apply.carryAfterRouteSave("p1", 7, 9); + writeState("uphill", { "40.00": "right" }, "p1"); + writeState("kindreset", { "45.00": "culvert" }, "p1"); + const part = apply.routeCarryPart("p1", 7); + out.carry = part.carry; + out.crossBody = part.cross.body; + out.uphillBeforeAfter = readState("uphill", "p1"); + await part.cross.after(undefined); + out.uphillAfter = readState("uphill", "p1"); + out.kindresetAfter = readState("kindreset", "p1"); out.carryCalls = [...calls]; // 추천 [적용] — as_initial · 잠금 막 · 초안 비움 · 다시 세움 diff --git a/resources/tester/helper_347_pipe_id_reconcile.cjs b/resources/tester/helper_347_pipe_id_reconcile.cjs index fd84022cb..98b4032a3 100644 --- a/resources/tester/helper_347_pipe_id_reconcile.cjs +++ b/resources/tester/helper_347_pipe_id_reconcile.cjs @@ -15,7 +15,7 @@ function loadTs(file, real) { const box = { exports: {} }; const localRequire = (request) => real.some((name) => request.endsWith(name)) - ? loadTs(path.join(path.dirname(file), `${request}.ts`), []) + ? loadTs(path.join(path.dirname(file), `${request}.ts`), ["common_util_pipe_match"]) // 472-6 : stub; new Function("exports", "module", "require", js)(box.exports, box, localRequire); return box.exports; diff --git a/resources/tester/helper_409_2_plan_release_form.cjs b/resources/tester/helper_409_2_plan_release_form.cjs index 00f71f6ff..a585c4219 100644 --- a/resources/tester/helper_409_2_plan_release_form.cjs +++ b/resources/tester/helper_409_2_plan_release_form.cjs @@ -25,6 +25,7 @@ globalThis.document = { querySelector: () => null }; const store = new Map(); const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6) "../b_page_state": { readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), 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_445_save_patch.cjs b/resources/tester/helper_445_save_patch.cjs index 178e5a965..6fcc1f069 100644 --- a/resources/tester/helper_445_save_patch.cjs +++ b/resources/tester/helper_445_save_patch.cjs @@ -73,6 +73,10 @@ const load = makeLoader( B06("B06_Section_UI_Cross_Culvert_Types"), B06("logic/B06_Section_Station_Swap"), B06("logic/B06_Section_UI_Page_Patches"), + B06("logic/B06_Section_Design_Keys"), // 칸 목록 · 얹기 · patch 꼴 한 벌(472-6) + B06("logic/B06_Section_Route_Carry"), + B06("B06_Section_Route_Carry_Patch"), + path.join(ROOT, "common_util", "common_util_pipe_match.ts"), path.join(ROOT, "A00_Common", "b_page_state.ts"), path.join(ROOT, "A00_Common", "design", "design_selection.ts"), ], 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_466_pipe_move_options.cjs b/resources/tester/helper_466_pipe_move_options.cjs index 52aece35f..101b18c99 100644 --- a/resources/tester/helper_466_pipe_move_options.cjs +++ b/resources/tester/helper_466_pipe_move_options.cjs @@ -25,6 +25,9 @@ function loadTs(file, stubs = {}) { // 세션(structure-pick) — 메모리 한 벌 const store = new Map(); const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), { + "../../common_util/common_util_pipe_match": loadTs( + path.join(ROOT, "common_util", "common_util_pipe_match.ts"), + ), // 찾는 거리 정의(472-6) "../b_page_state": { readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), 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_179_4_detail_table.py b/resources/tester/test_179_4_detail_table.py index 2407afd01..347366ea8 100644 --- a/resources/tester/test_179_4_detail_table.py +++ b/resources/tester/test_179_4_detail_table.py @@ -246,7 +246,7 @@ def test_손질_없으면_도면_표_그대로(): def test_살림_목록_짝에_detail_table(): assert "detail_table" in USER_TOUCHED_KEYS - text = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + text = (ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts").read_text(encoding="utf-8") # 목록 한 벌(472-6) block = re.search(r"export const USER_TOUCHED_KEYS = \[(.*?)\] as const;", text, re.S) assert block and '"detail_table"' in block.group(1) diff --git a/resources/tester/test_194_1_berm_depth.py b/resources/tester/test_194_1_berm_depth.py index c8765cb14..95e726918 100644 --- a/resources/tester/test_194_1_berm_depth.py +++ b/resources/tester/test_194_1_berm_depth.py @@ -202,7 +202,7 @@ def test_patch_칸_왕복() -> None: with pytest.raises(ValidationError): CrossSectionPatch(chainage_m=40, berm_depth_left_m=-0.1) # 도로 위 금지 assert {"berm_depth_left_m", "berm_depth_right_m"} <= set(USER_TOUCHED_KEYS) - refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + refresh = (ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts").read_text(encoding="utf-8") # 목록 한 벌(472-6) assert '"berm_depth_left_m",' in refresh and '"berm_depth_right_m",' in refresh persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert "patch.berm_depth_left_m = sides.left" in persist diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index e7b4b8cf8..8094a4d86 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -77,7 +77,7 @@ def test_브라우저가_CAD_칸을_저장_patch_에_싣는다(): state = read("A00_Common/b_page_state.ts") assert '"structure-note-cads": { bucket: "draft", scope: "route" }' in state # 캐시 설계 → B05 [저장] 길(`crossPatchesFromCache`)도 같은 목록 - assert '"structure_note_cad",' in read("B06_Section/B06_Section_Cross_Refresh.ts") + assert '"structure_note_cad",' in read("B06_Section/logic/B06_Section_Design_Keys.ts") # 472-6 # 모달 = 빈 CAD(캔버스만) · 글 상자 없음 detail = read("B06_Section/B06_Section_UI_Cross_Detail.ts") assert "textarea" not in detail and "mountNoteCad(" in detail and "noteCad?.destroy()" in detail 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_318_cross_card.py b/resources/tester/test_318_cross_card.py index 76b0db181..22056e53a 100644 --- a/resources/tester/test_318_cross_card.py +++ b/resources/tester/test_318_cross_card.py @@ -252,7 +252,7 @@ def test_wiring_sources() -> None: def test_structure_keys_mirror() -> None: """서버 · 브라우저 열쇠 목록이 같음 · 모두 승계 목록 안.""" - ts = _src("B06_Section", "B06_Section_Api_Culvert_Options.ts") + ts = _src("B06_Section", "logic", "B06_Section_Design_Keys.ts") # 목록 한 벌(472-6) block = ts[ts.index("export const STRUCTURE_ADJUST_KEYS = [") :] block = block[: block.index("] as const")] assert re.findall(r'"([a-z_]+)"', block) == list(STRUCTURE_ADJUST_KEYS) diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index 527e91883..fcf477f9b 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", "commit", "overlay:off"] # 초안 전 시설 버림 assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시 assert out["rebuilt"] == 1 @@ -114,9 +112,12 @@ def test_노선_반영_단추_없앰_배선(): apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts") assert "addEventListener" not in apply # 단추 듣기 없음 — [저장]이 부름 save = _read("A00_Common/design/design_save.ts") - assert save.index('"route",') < save.index('"pipes",') assert "await pendingRouteSave(ctx.projectId)" in save - assert "carryAfterRouteSave(ctx.projectId, fromRouteId, routeId)" in save + # 6-B — 노선 초안 = 한 본문 `route` 칸 · 서버가 맨 앞에서 노선 저장(옛 차례 길 걷음) + assert "route ? { route, carry: carried?.carry } : {}" in save + assert "carryAfterRouteSave" not in save and "saveRouteDraft" not in save + server = _read("B06_Section/B06_Section_Router_DesignSave.py") + assert server.index('step("route"') < server.index('step("settings"') initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts") at = initial.index("onApply: (picked) => {") assert "saveRecommendedRoute(" in initial[at : at + 900] @@ -129,8 +130,11 @@ def test_넘김_알림은_결과_한_줄에_합침(): keys = _read("B06_Section/B06_Section_Station_Keys.ts") assert "{ quiet = false }: { quiet?: boolean } = {}," in keys assert "if (outside.size && !quiet)" in keys - apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts") - assert "carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true })" in apply + # 6-B — 서버 넘김이 버린 수(`outside` · 측점 열쇠 + 누가거리 칸)를 결과 한 줄에(`routeSaveSummary`) + patch = _read("B06_Section/B06_Section_Route_Carry_Patch.ts") + assert "outside: keyed.outside.size + chain.outside.size" in patch + save = _read("A00_Common/design/design_save.ts") + assert "routeSaveSummary(saved, carryOutside)" in save def test_노선_반영_글자_없음(): 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_417_route_carry.py b/resources/tester/test_417_route_carry.py index 00b164b3e..0d807c650 100644 --- a/resources/tester/test_417_route_carry.py +++ b/resources/tester/test_417_route_carry.py @@ -41,7 +41,7 @@ from B06_Section.B06_Section_Router_Design import ( # noqa: E402 ) from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 -TS_KEYS = ROOT / "B06_Section" / "B06_Section_Station_Keys.ts" +TS_KEYS = ROOT / "B06_Section" / "logic" / "B06_Section_Route_Carry.ts" # 넘김 규칙 한 벌(472-6) @pytest.fixture(scope="module") 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 993c20920..9afe1f1e8 100644 --- a/resources/tester/test_445_save_patch.py +++ b/resources/tester/test_445_save_patch.py @@ -231,24 +231,34 @@ def test_M5_새_행은_버린_수에_안_셈(): def test_H5_노선_저장_뒤_본문_M7_kindreset_옮김(): - store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8") - body = store[store.index("export async function saveCachedCrossPatches") :] + # 472-6 6-B — 노선 저장 뒤 본문 = 서버 Node 넘김(`routeCarryPatches` · 옛 `saveCachedCrossPatches` · + # `sessionOverlay` 몸통 그대로) · 표준 횡단면 · 풍선 = 브라우저 횡단 조각(`routeCarryPart`) + patch = (ROOT / "B06_Section" / "B06_Section_Route_Carry_Patch.ts").read_text(encoding="utf-8") + body = patch[patch.index("export function routeCarryPatches") :] for needle in ( - "sessionOverlay(projectId, routeId)", - "readStandardCrossSession(projectId)", - "balloon_offsets: balloonOffsetsPayload()", + "crossPatchesFromCache(detail, kindChanged, savedAt)", + 'read("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 ) # 472-6 횡단 조각 + # 넘김 규칙 = logic 한 벌(472-6 6-B) · 세션 겉이 kindreset 을 프로젝트 칸에 다시 씀 + rules = (ROOT / "B06_Section" / "logic" / "B06_Section_Route_Carry.ts").read_text( + encoding="utf-8" + ) + carry = rules[rules.index("export function carryChainageStates") :] + assert 'states.kindreset = rekey("revetx", resets)' in carry keys = (ROOT / "B06_Section" / "B06_Section_Station_Keys.ts").read_text(encoding="utf-8") - carry = keys[keys.index("export function carryChainageStates") :] - assert 'writeState("kindreset", rekey("revetx", resets), projectId)' in carry + assert 'if (name === "kindreset") writeState("kindreset", next, projectId);' in keys idle = keys[keys.index("function moveIdleStates") :] assert 'writeState("kindreset", move(resets), projectId)' in idle 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..fd40a72be --- /dev/null +++ b/resources/tester/test_472_6_route_save.py @@ -0,0 +1,392 @@ +"""PLAN 472-6 6-B — 노선 바뀐 [저장]도 한 본문 · 다시 얹기 서버 Node(같은 TS). + + ① 넘김 — 브라우저 옛 길(세션 함수를 옛 `carryAfterRouteSave` → `saveCachedCrossPatches` + 차례로) 결과 == 서버 Node `stations.carry`(`routeCarryPatches`) 결과(patch · 버린 수) · + 손 상단측 안 넘김(요구 274). + ② 다시 얹기 — 관 · 구조물 초안(`rebasePipeDraft` · `rebaseStructureDraft`) == + Node `facility.rebase`. + ③ 옛 코드 실패 — 한 본문이 `route` · `carry` 칸을 받음 · 브라우저가 노선 저장 창구를 안 부름. + ④ 실제 창구(검증_sub8 · DB · storage) — 노선 차례 뒤 실패(시험 갈래 · 넘김 실패) = 노선 행 · + 자식 표 · storage 그대로 · 정상 한 본문 = 옛 차례(노선 저장 → 다시 얹기 → 횡단 저장)와 + 행 · 파일 같음. +검증 프로젝트가 없는 자리(다른 PC · DB 없음)는 ④ 건너뜀. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_route_save.py -q +""" + +from __future__ import annotations + +import asyncio +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path +from uuid import UUID + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +sys.path.insert(0, str(ROOT)) + +from common_util import common_util_save_guard as guard # noqa: E402 +from common_util.common_util_calc_runner import run_calc # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _node(*args: str, timeout: int = 300) -> str: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", *args], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=timeout + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout + + +@pytest.fixture(scope="module") +def out(tmp_path_factory) -> dict: + """vite ssr 로 진짜 모듈을 묶어 옛 길을 돌림(417 꼴).""" + work = tmp_path_factory.mktemp("r6b") + _node( + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_472_6_route_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ) + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_472_6_route_entry.js", bundle) + return json.loads(_node(str(HERE / "helper_472_6_route_carry.mjs"), str(bundle))) + + +@pytest.fixture(scope="module") +def carried(out) -> dict: + inputs = { + **out["carry"], + "detail": out["detail"], + "pipes_before": out["pipesBefore"], + "pipes_after": out["pipesAfter"], + } + result = run_calc(["stations.carry"], {"carry": inputs}) + assert result is not None + return result["stations.carry"] + + +def _by_chainage(patches: list[dict]) -> dict[str, dict]: + return {f"{patch['chainage_m']:.2f}": patch for patch in patches} + + +def test_넘김_옛_브라우저_길과_서버_Node_같음(out, carried) -> None: + assert carried["patches"] == out["oldPatches"] + assert carried["outside"] == out["oldOutside"] + + +def test_넘김_값_사용자_칸(out, carried) -> None: + """같은 값이 뜻있는지 — 카드 선택 · 사면 쉬프트 · 암선 · 암 경사(MC 옮김) · 관 조절 · 메모가 + 새 측점에.""" + by = _by_chainage(carried["patches"]) + assert by["20.00"]["ground_type"] == "soil" and by["20.00"]["two_stage_slope"] is True + assert by["20.00"]["ditch_type"] == "u_flume" + assert "section_mode" not in by["20.00"] # 단면 유형 = 새 지형이 정함 + assert by["20.00"]["structure_note"] == "앞 메모" + assert by["40.00"]["shoulder_extend_left_m"] == 0.5 + assert "revet_adjust" not in by["40.00"] # 일반 측점 옹벽 조절 = 안 넘김(관 · 구조물 열쇠만) + assert by["60.00"]["display_half_width_m"] == 6 # 세션 폭이 저장분(5) 위에 + assert by["103.00"]["rock_boundary_offset_m"] == 1.5 + assert by["112.00"]["cut_slope_ratio_user"] == 0.8 # MC 110 → 112(곡선 열쇠 옮김) + pipe = by["83.00"] + assert pipe["revet_adjust"]["outlet"]["x"] == 0.3 # 관 따라 80 → 83 + assert pipe["revet_adjust"]["inlet"]["h"] == 1.8 # 관 옵션 벽 높이(저장 관 · 새 자리) + assert pipe["basin_adjust"]["depth_m"] == 1.2 and pipe["detail_table"] == {"rows": 2} + assert pipe["revet_link_detached"] is True + assert not any( + p.get("structure_reset") for p in carried["patches"] + ) # 일반 측점 종류 바꿈 = 버림 + assert carried["outside"] == out["oldOutside"] >= 4 # 구조물 130 고침 셋 · 40 옹벽 · 60 종류 + + +def test_손_상단측은_안_넘김_요구274(out) -> None: + assert "uphill" not in out["carry"]["states"] + assert out["carry"]["states"]["kindreset"] == {"60.00": "culvert"} + + +def test_다시_얹기_옛_함수와_서버_Node_같음(out) -> None: + result = run_calc(["facility.rebase"], {"rebase": out["rebaseInput"]}) + assert result is not None + assert result["facility.rebase"] == out["oldRebase"] + pipes = {pipe["id"]: pipe["chainage_m"] for pipe in result["facility.rebase"]["pipes"]} + assert pipes == {"p80": 83, "p20": 25, "pnew": 120} # 따라감 · 사용자 옮김 그대로 · 새 교차 관 + assert result["facility.rebase"]["structures"][0]["chainage_m"] == 63 + + +def test_옛_코드_실패_한_본문이_노선을_받음() -> None: + from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest + + request = DesignSaveRequest.model_validate( + { + "route_id": 7, + "route": {"draft": {"nodes": [[0, 0], [10, 0]]}}, + "carry": {"states": {}, "from_keys": None, "owners": [], "pipe_ids": None}, + } + ) + assert request.route is not None and request.carry is not None + save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8") + assert "saveRouteDraft" not in save and "carryAfterRouteSave" not in save + assert re.search(r"postDesignSave\([^;]*route \? \{ route, carry", save, re.S) + apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") + assert "export async function carryAfterRouteSave" not in apply + + +# ── 실제 창구 ────────────────────────────────────────────────────────────── + +PROJECT = "9872e9a7-e483-427b-a6b2-72cdbc063bc3" # 검증_sub8(OWNERS.md) +VOLATILE = ("created_at", "updated_at", "confirmed_at", "started_at", "completed_at") + + +def _db_ready() -> Path | None: + try: + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from common_util.common_util_storage import resolve_stored_project_path + from config.config_db import close_db_pool, init_db_pool + + async def find(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection: + return await get_project_storage_relative_path(connection, UUID(PROJECT)) + finally: + await close_db_pool() + + root = Path(resolve_stored_project_path(asyncio.run(find()))) + return root if (root / "B05_Profile" / "route").is_dir() else None + except Exception: + return None + + +ROOT_DIR = _db_ready() +live = pytest.mark.skipif(ROOT_DIR is None, reason="검증_sub8 · DB 없음") + + +def _rows() -> dict: + from config.config_db import close_db_pool, init_db_pool + + async def run(): + pool = await init_db_pool() + try: + async with pool.acquire() as connection: + return await guard.snapshot_rows(connection, PROJECT, surface=True) + finally: + await close_db_pool() + + return asyncio.run(run()) + + +def _state(root: Path) -> tuple[dict, dict]: + """(행 — 시각 칸 뺌, 파일 내용 해시) — 노선 바뀐 저장 범위 전부.""" + import hashlib + + clean = { + table: [{k: str(v) for k, v in row.items() if k not in VOLATILE} for row in values] + for table, values in _rows().items() + } + files = { + relative: hashlib.sha1(path.read_bytes()).hexdigest() + for relative, path in guard._files(root, guard.STORAGE_SCOPE + guard.PIPE_SCOPE).items() + if not relative.startswith("_derived/") + } + return clean, files + + +def _plan_draft() -> dict: + """지금 계획노선 노드 · 곡선 그대로 + 가운데 노드 하나를 3 m 옆으로(노선 끌기).""" + from B05_Profile.B05_Profile_Router_Replan import read_route_plan + from config.config_db import close_db_pool, init_db_pool + + async def run(): + await init_db_pool() + try: + return await read_route_plan(UUID(PROJECT)) + finally: + await close_db_pool() + + plan = asyncio.run(run()) + nodes = plan["nodes"] + at = len(nodes) // 2 + points = [[float(n["x"]), float(n["y"])] for n in nodes] + points[at][1] += 3.0 + return { + "rev": 1, + "nodes": points, + "curveOn": [True] * len(nodes), + "curveRadius": [None] * len(nodes), + "stationSnap": bool(plan.get("station_snap")), + "stationSnapStep": plan.get("station_snap_step"), + } + + +def _save(body: dict): + from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest, save_design + from config.config_db import close_db_pool, init_db_pool + + async def run(): + await init_db_pool() + try: + return await save_design(UUID(PROJECT), DesignSaveRequest.model_validate(body), None) + finally: + await close_db_pool() + + response = asyncio.run(run()) + return response.status_code, json.loads(bytes(response.body).decode("utf-8")) + + +def _latest_route_id() -> int: + return max(int(row["id"]) for row in _rows()["routes"]) + + +def _body(draft: dict, **extra) -> dict: + return { + "route_id": _latest_route_id(), + "route": {"draft": draft}, + "carry": {"states": {}, "from_keys": None, "owners": [], "pipe_ids": None}, + "cross": {}, + **extra, + } + + +@live +def test_노선_차례_뒤_실패면_노선_행_storage_그대로(monkeypatch) -> None: + before = _state(ROOT_DIR) + monkeypatch.setenv("AISLO_DESIGN_SAVE_FAIL_AFTER", "route") + status, body = _save(_body(_plan_draft())) + assert status == 409 and body["step"] == "route" and body["restored"] is True, body + assert _state(ROOT_DIR) == before # 새 노선 행 · 자식 표 · 노선 파일 · 관 · 구조물 그대로 + + +@live +def test_넘김_실패면_노선까지_그대로(monkeypatch) -> None: + from B06_Section import B06_Section_Router_DesignSave_Route as moving + + before = _state(ROOT_DIR) + + def broken(ids, inputs): + if ids == ["stations.carry"]: + raise RuntimeError("넘김 일부러 실패") + return run_calc(ids, inputs) + + monkeypatch.setattr(moving, "run_calc", broken) + status, body = _save(_body(_plan_draft())) + assert status == 409 and body["step"] == "cross" and body["restored"] is True, body + assert "넘김 일부러 실패" in body["message"] + assert _state(ROOT_DIR) == before + + +def _normalized(state: tuple[dict, dict], new_ids: set[int]) -> tuple[dict, dict]: + """새 노선 번호 · 행 id · 계산한 때(`computed_at`)를 지운 비교 꼴 — 옛 길 · 새 길이 다른 + 자동 번호 · 시각을 받음.""" + rows, files = state + numbers = "|".join(str(i) for i in sorted(new_ids, reverse=True)) or "x" + # 3D 파일 = 0 채운 번호(`corridor_0444.json`) + rename = re.compile(rf"(? None: + from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest + from B05_Profile.B05_Profile_Router_Replan import replan_route + from B06_Section import B06_Section_Router_DesignSave_Route as moving + from B06_Section.B06_Section_Router_Confirm import save_sections + from B06_Section.B06_Section_Schema import SectionConfirmRequest + from config.config_db import close_db_pool, get_db_pool, init_db_pool + + start = _state(ROOT_DIR) + draft = _plan_draft() + old_ids = {int(row["id"]) for row in _rows()["routes"]} + + async def old_way(): + """옛 브라우저 차례 — 노선 저장(`/route/replan`) → 횡단 넘김 patch → 횡단 저장.""" + await init_db_pool() + try: + async with get_db_pool().acquire() as connection: + rows = await guard.snapshot_rows(connection, PROJECT, surface=True) + files = guard.snapshot_storage(ROOT_DIR, guard.STORAGE_SCOPE + guard.PIPE_SCOPE) + before = await moving.saved_facilities(UUID(PROJECT)) + saved = await replan_route( + UUID(PROJECT), RouteSaveRequest.model_validate({"draft": draft}) + ) + carried = await moving.carry_cross(UUID(PROJECT), int(saved["route_id"]), None, before) + await save_sections( + UUID(PROJECT), + int(saved["route_id"]), + SectionConfirmRequest.model_validate({"cross_patches": carried["patches"] or None}), + ) + return rows, files + finally: + await close_db_pool() + + rows, files = asyncio.run(old_way()) + old = _state(ROOT_DIR) + + async def undo(): + await init_db_pool() + try: + files.restore() + async with get_db_pool().acquire() as connection: + await guard.restore_rows(connection, PROJECT, rows) + finally: + await close_db_pool() + + asyncio.run(undo()) + assert _state(ROOT_DIR) == start + try: + status, body = _save(_body(draft)) + assert status == 200 and body["status"] == "success", body + assert set(body["steps"]) == {"route", "carry", "cross"} + new = _state(ROOT_DIR) + ids = {int(row["id"]) for row in _rows()["routes"]} | { + int(row["id"]) for row in json.loads(json.dumps(old[0]["routes"])) + } + ids -= old_ids + (rows_new, files_new), (rows_old, files_old) = _normalized(new, ids), _normalized(old, ids) + assert [t for t in rows_new if rows_new[t] != rows_old.get(t)] == [] + assert ( + sorted( + k + for k in files_new.keys() | files_old.keys() + if files_new.get(k) != files_old.get(k) + ) + == [] + ) + finally: + asyncio.run(undo()) + files.discard() + assert _state(ROOT_DIR) == start + + +def test_저장한_노선_초안은_버림으로_되돌리지_않음() -> None: + """화면 흠(6-B 화면 검증) — 저장 뒤 노선 초안 칸을 비우면 묶음이 「초안 버림」으로 보고 초안 전 관 · + 구조물 초안을 세션에 되써 다음 [저장]이 옛 자리로 되돌렸음 → 저장 성공이면 초안 전 값을 버림(commit).""" + apply = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") + body = apply[apply.index("export function routeSaved") :] + body = body[: body.index("\n}\n")] + assert body.index("commitRouteFacilities(projectId);") < body.index('set("routeDraft", null)') + page = (ROOT / "A00_Common" / "design" / "design_pipeline_page.ts").read_text(encoding="utf-8") + assert "commit: () => void (beforeDraft = null)," in page + assert "if (!beforeDraft) return;" in page # 버린 차례 = 초안 전 값이 있을 때만 되씀 diff --git a/resources/tester/test_b05_b06_selection_single.py b/resources/tester/test_b05_b06_selection_single.py index e095972c5..f180850b4 100644 --- a/resources/tester/test_b05_b06_selection_single.py +++ b/resources/tester/test_b05_b06_selection_single.py @@ -54,7 +54,12 @@ def test_거리_정의는_한_곳뿐이고_옛_0_05는_없다(): text = _read(os.path.relpath(os.path.join(current, name), ROOT)) if re.search(r"\b(const|let)\s+PIPE_MATCH_M\b", text): definitions.append(name) - assert definitions == ["design_selection.ts"] + assert ( + definitions == [] + ) # 정의 = common_util 한 곳(서버 Node 묶음도 끔 · 472-6) · 페이지는 내줌만 + station = _read("common_util/common_util_pipe_match.ts") + assert re.search(r"export const PIPE_MATCH_M = 0\.51;", station) + assert "export { PIPE_MATCH_M }" in _read("A00_Common/design/design_selection.ts") for name, folder in ( ("B05_Profile_UI_Structures_Panel.ts", "B05_Profile"), ("design_view_profile.ts", "A00_Common/design"), diff --git a/resources/tester/test_b06_cut_slope_user_input.py b/resources/tester/test_b06_cut_slope_user_input.py index 52b4146d4..7dd605cc9 100644 --- a/resources/tester/test_b06_cut_slope_user_input.py +++ b/resources/tester/test_b06_cut_slope_user_input.py @@ -90,7 +90,9 @@ def test_0_은_표준값으로_되돌림이다(): def test_다시_계산해도_사용자_값이_남는다(): """표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 (사용자 원문 끝줄).""" assert "cut_slope_ratio_user" in USER_TOUCHED_KEYS - assert '"cut_slope_ratio_user"' in _REFRESH.read_text(encoding="utf-8") + assert '"cut_slope_ratio_user"' in (_B06 / "logic" / "B06_Section_Design_Keys.ts").read_text( + encoding="utf-8" + ) # 목록 한 벌(472-6) def test_계산_전에_넘어간다(): 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_b06_user_touched_keys.py b/resources/tester/test_b06_user_touched_keys.py index 6832a1f2f..bf048edfa 100644 --- a/resources/tester/test_b06_user_touched_keys.py +++ b/resources/tester/test_b06_user_touched_keys.py @@ -11,7 +11,8 @@ from pathlib import Path from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS ROOT = Path(__file__).resolve().parents[2] -TS_PATH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts" +TS_PATH = ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts" # 목록 한 벌(472-6) +REFRESH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts" # 브라우저 목록에만 있고 서버 목록에는 없는 둘 — 부르는 쪽이 따로 붙인다. _CALLER_ADDED = {"status", "pavement_suggested"} @@ -36,7 +37,7 @@ def test_서버와_브라우저_목록이_같다(): def test_브라우저_목록도_한_벌이다(): """보존 목록·저장 payload 가 같은 한 벌을 쓰는지 — 손나열이 되살아나면 깨진다.""" - text = TS_PATH.read_text(encoding="utf-8") + text = REFRESH.read_text(encoding="utf-8") assert "...USER_TOUCHED_KEYS" in text, "PRESERVED_KEYS 가 목록을 다시 적고 있음" store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8") assert "USER_TOUCHED_KEYS" in store, "저장 payload 가 목록을 안 씀" 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: diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py index 55f08026f..2c4af4fe5 100644 --- a/resources/tester/test_masshaul_single_source.py +++ b/resources/tester/test_masshaul_single_source.py @@ -18,6 +18,7 @@ def _read(relative: str) -> str: REPO = _read("B06_Section/B06_Section_Repository.py") B06_ROUTER = _read("B06_Section/B06_Section_Router.py") REFRESH = _read("B06_Section/B06_Section_Cross_Refresh.ts") +KEYS = _read("B06_Section/logic/B06_Section_Design_Keys.ts") B05_PREVIEW = _read("B05_Profile/B05_Profile_UI_Profile_Preview.ts") # 설계 재계산 자리는 700줄 제한으로 `_UI_Page_Design_Sync` 로 떨어져 나갔다(2026-09-13). B06_PAGE = _read("A00_Common/design/design_view_cross.ts") + _read( @@ -66,7 +67,7 @@ def test_cross_refresh_preserves_user_fields(): "revet_link_detached", "revet_follow_grade", ): - assert f'"{field}"' in REFRESH # preserveUserFields 의 보존 필드 목록 + assert f'"{field}"' in KEYS # preserveUserFields 의 보존 필드 목록(한 벌 · 472-6) def test_cross_refresh_lays_structure_areas_once():