From 431791c2571990d520a4c9c059dd5fc502a665d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 8 Aug 2026 19:50:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(B03,B05,B06):=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=81=9D=EA=B9=8C=EC=A7=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=9E=A0=EA=B8=88=20+=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=EB=A1=9C=20(=EA=B2=B0=ED=95=A8=203=C2=B76=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=B8=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업로드 잠금(결함 3) - 종전에는 전처리(B04)만 끝나면 버튼이 풀려, 노선·횡단이 도는 동안 자료를 또 올릴 수 있었다. isInitialPipelineRunning()/pollInitialPipeline()을 두어 **횡단 단계가 열릴 때까지**(3단계가 NOT_STARTED를 벗어날 때까지) 잠근다. - relockWhileInitialPipelineRuns(): 새로고침·재진입해도 계산이 도는 중이면 다시 잠그고 스피너를 붙인다. 잠금이 화면 상태로만 있으면 새로고침 한 번에 풀렸다. - 보관함 연결 경로도 같은 대기 흐름을 탄다. 자료 없으면 대시보드(결함 6) - b_missing_data_guard 신설 — 근거 자료가 없으면 한 줄 안내 후 B01로 보낸다. 새 자료가 올라오면 서버가 옛 산출물을 지우므로, 열어 둔 뒷단계 화면은 빈 화면이 아니라 대시보드로 나가야 한다(2026-08-08 사용자 지시). - B05: 확정 지표면이 없으면 이동. B06: 노선 또는 종단면이 없으면 이동. 검증: 자료가 없는 신규 프로젝트로 B05 진입 → 안내 후 #/b01-account 로 이동함을 헤드리스 크롬으로 확인. typecheck·prettier 통과, 정적 번들 재빌드. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_missing_data_guard.ts | 48 ++++++++++++++++++++++++ B03_FileInput/B03_FileInput_UI_Page.ts | 29 +++++++++++++- B03_FileInput/B03_FileInput_UI_Upload.ts | 43 +++++++++++++++++++++ B05_Profile/B05_Profile_UI_Page.ts | 5 ++- B06_Section/B06_Section_UI_Page.ts | 6 ++- 5 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 A00_Common/b_missing_data_guard.ts diff --git a/A00_Common/b_missing_data_guard.ts b/A00_Common/b_missing_data_guard.ts new file mode 100644 index 00000000..9adaa17e --- /dev/null +++ b/A00_Common/b_missing_data_guard.ts @@ -0,0 +1,48 @@ +/* ============================================================================= + * b_missing_data_guard.ts + * "불러올 자료가 없으면 대시보드로" — B04 이후 화면 공용 규칙. + * + * 새 입력 자료를 올리면 서버가 옛 계산 결과를 통째로 지우고 B06까지 다시 계산한다. + * 그래서 사용자가 열어 둔 뒷단계 화면은 근거 자료가 사라진 상태가 된다. 그 화면에서 + * 빈 그래프를 보여주거나 오류만 띄우면 사용자는 무엇이 잘못됐는지 알 수 없다. + * + * 이 화면들은 자료를 못 찾으면 **한 줄 안내 후 대시보드로 돌려보낸다**(2026-08-08 사용자 + * 지시). 사용자는 대시보드에서 그 프로젝트가 다시 계산 중임을 보고 기다리면 된다. + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { showToast } from "@ui/ui_template_elements"; +import { navigateTo } from "./router"; + +const DEFAULT_MESSAGE = "입력 자료가 새로 올라와 이 화면의 자료가 없습니다. 대시보드로 돌아갑니다."; + +let redirecting = false; + +/** + * 자료가 없어 화면을 세울 수 없을 때 호출한다. 안내를 띄우고 대시보드로 보낸다. + * 여러 곳에서 동시에 불려도 이동은 한 번만 한다. + */ +export function leaveForDashboard(message: string = DEFAULT_MESSAGE): void { + if (redirecting) return; + redirecting = true; + showToast(message, "warning"); + // 토스트를 읽을 시간을 조금 준다. + window.setTimeout(() => { + redirecting = false; + navigateTo(ROUTES.B01_ACCOUNT); + }, 1200); +} + +/** + * 필수 자료가 비었으면 대시보드로 보내고 `true`를 돌려준다. + * 호출부는 `if (missingDataLeaves(x)) return;` 형태로 바로 빠져나가면 된다. + */ +export function missingDataLeaves(value: unknown, message?: string): boolean { + const empty = + value === null || + value === undefined || + (Array.isArray(value) && value.length === 0) || + value === false; + if (empty) leaveForDashboard(message); + return empty; +} diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 3ca2b7c8..b7ee750c 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -23,7 +23,8 @@ import { import { restoreB03ProjectState } from "./B03_FileInput_State"; import { confirmReplaceUpload, - pollWF1Analysis, + isInitialPipelineRunning, + pollInitialPipeline, renderUploadResults, uploadOneFile, } from "./B03_FileInput_UI_Upload"; @@ -466,7 +467,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { /** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */ const pollAnalysis = (projectId: string): Promise => - pollWF1Analysis(projectId, (message) => { + pollInitialPipeline(projectId, (message) => { pageError.textContent = message; showToast(message, "warning"); }); @@ -508,6 +509,29 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } } + /** + * 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다. + * 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다. + */ + async function relockWhileInitialPipelineRuns(): Promise { + if (!activeProjectId || isUploading) return; + try { + const state = await fetchWorkflowState(activeProjectId); + if (!isInitialPipelineRunning(state)) return; + } catch { + return; // 상태를 못 읽으면 잠그지 않는다 — 서버가 막아 준다. + } + setUploading(true); + showToast(L("B03_File_Analysis_InProgress"), "info"); + try { + const done = await pollAnalysis(activeProjectId); + if (done) showToast(L("B03_File_Upload_Success"), "success"); + } finally { + setUploading(false); + void applyUploadOverview(); + } + } + async function startChunkedUpload(targetStates = selectedStates()): Promise { if (isUploading) return; // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. @@ -670,6 +694,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { root.replaceChildren(layout.root); for (const slot of slots.keys()) renderSlot(slot); + void relockWhileInitialPipelineRuns(); void registerB03ServiceWorker(); void applyUploadOverview(); void detectPausedUploads(); diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index 4f6af066..6ce27fd4 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend"; +import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav"; import { createButton } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { @@ -168,6 +169,48 @@ export async function uploadOneFile( * 지표면 분석(WF1)이 끝날 때까지 5초 간격으로 확인한다. * 자동 확정이 보류되면 `onHold`로 사유를 넘기고 대기를 끝낸다. */ +/** + * 초기 자동 계산이 아직 도는 중인지 — 업로드 버튼을 잠가 둘지 판단하는 기준. + * + * 자료를 올리면 서버가 전처리(B04) → 노선(B05) → 횡단(B06)까지 혼자 이어서 돈다. 그 사이에 + * 자료를 또 올리면 분석 두 개가 같은 산출물 자리에서 부딪힌다. 그래서 **횡단 단계가 + * 시작될 때까지**를 "도는 중"으로 본다 — 체인이 B06을 확정하면 3단계가 NOT_STARTED를 + * 벗어난다(2026-08-08 사용자 지시). + * + * 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다. + */ +export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean { + if (!state?.stages?.length) return false; + const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo); + const fileInput = stageAt(0); + const preprocess = stageAt(1); + const section = stageAt(3); + if (fileInput?.state !== "COMPLETE") return false; + if (preprocess?.state === "FAILED") return false; + return section?.state === "NOT_STARTED"; +} + +/** 초기 자동 계산(B04~B06)이 끝날 때까지 기다린다. 끝나면 true. */ +export async function pollInitialPipeline( + projectId: string, + onHold: (message: string) => void, + maxAttempts = 360, +): Promise { + const analysisDone = await pollWF1Analysis(projectId, onHold, maxAttempts); + if (!analysisDone) return false; + // 전처리가 끝나도 노선·횡단이 이어서 돈다 — 3단계가 열릴 때까지 더 기다린다. + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const state = await fetchWorkflowState(projectId); + if (!isInitialPipelineRunning(state)) return true; + } catch { + /* 일시적인 조회 실패는 다음 주기에 다시 확인한다. */ + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + return false; +} + export async function pollWF1Analysis( projectId: string, onHold: (message: string) => void, diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 2ca77a05..e67265c6 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -36,6 +36,7 @@ import { } from "./B05_Profile_UI_Markers"; import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel"; import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; +import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { navigateTo } from "../A00_Common/router"; import { createSelectionSync } from "./B05_Profile_UI_Selection"; import { createRouteViewer } from "./B05_Profile_UI_Viewer"; @@ -711,7 +712,9 @@ export async function renderB05Route(root: HTMLElement): Promise { // ⑤ 3D 지형 — 가장 무거우므로 맨 마지막. if (!confirmedSurface) { - showToast("확정된 지표면 모델이 없습니다.", "error"); + // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. + leaveForDashboard(); + return; } else { // 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다. const confirmed = await fetchConfirmedSurface(activeProjectId); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 8aaf412e..5348838c 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -1,4 +1,5 @@ import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { navigateTo } from "../A00_Common/router"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { @@ -679,7 +680,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { - renderMessage(L("B06_Profile_Calculate_In_B05")); + // 자료가 통째로 없으면(새 자료가 올라와 옛 결과가 지워진 경우) 대시보드로 돌려보낸다. + leaveForDashboard(); return; } @@ -689,7 +691,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { try { const existing = await getSections(projectId, context.route_id); if (!existing.longitudinal) { - renderMessage(L("B06_Profile_Calculate_In_B05")); + leaveForDashboard(); return; } // 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).