diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index e9664935..5ed7a30c 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -146,6 +146,42 @@ export async function fetchUploadStatus( return await readJsonOrThrow(response); } +export interface UploadOverviewFile { + input_file_id: number; + file_type: string; + original_filename: string; + file_size_mb: number; + status: string; + uploaded_at: string | null; +} + +export interface UploadOverviewSession { + upload_session_id: string; + original_filename: string; + file_size_bytes: number; + total_chunks: number; + completed_chunks: number; + progress_percent: number; + updated_at: string | null; +} + +export interface UploadOverviewResponse { + status: string; + files: UploadOverviewFile[]; + pending_sessions: UploadOverviewSession[]; + required_complete: boolean; + analysis_complete: boolean; +} + +/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */ +export async function fetchUploadOverview(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, { + method: "GET", + credentials: "include", + }); + return await readJsonOrThrow(response); +} + export interface WF1AnalysisStatus { project_id: string; status: "pending" | "in_progress" | "completed" | "failed"; diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index f9ed89a7..9b472e82 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -290,3 +290,51 @@ async def mark_upload_session_failed( """, (session_id,), ) + + +async def list_project_input_files( + connection: aiomysql.Connection, + project_id: UUID, +) -> list[dict[str, Any]]: + """업로드 완료된 입력 파일 목록(재접속 현황 표시용) — 서버가 정본이다. + + 같은 파일명을 다시 올리면 새 레코드가 쌓이므로 파일명별 최신 것만 남긴다. + """ + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT f.id, f.file_type, f.original_filename, f.file_size_mb, f.status, + f.created_at + FROM input_files f + INNER JOIN ( + SELECT MAX(id) AS id + FROM input_files + WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED') + GROUP BY original_filename + ) latest ON latest.id = f.id + ORDER BY f.id ASC + """, + (str(project_id),), + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + +async def list_incomplete_upload_sessions( + connection: aiomysql.Connection, + project_id: UUID, +) -> list[dict[str, Any]]: + """중단된(미완료) 청크 업로드 세션 목록 — 재접속 시 이어올리기 안내용.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT id, original_filename, file_size_bytes, chunk_size_bytes, + total_chunks, completed_chunks, updated_at + FROM upload_sessions + WHERE project_id = %s AND status = 'in_progress' + ORDER BY updated_at DESC + """, + (str(project_id),), + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index afcbef4b..f468ba44 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -30,6 +30,8 @@ from B03_FileInput.B03_FileInput_Repository import ( get_project_storage_relative_path, get_upload_session, list_completed_chunk_indexes, + list_incomplete_upload_sessions, + list_project_input_files, mark_upload_session_completed, mark_upload_session_failed, upsert_upload_chunk, @@ -42,6 +44,9 @@ from B03_FileInput.B03_FileInput_Schema import ( FileUploadResponse, UploadedFileResult, UploadFinalizeRequest, + UploadOverviewFile, + UploadOverviewResponse, + UploadOverviewSession, UploadStatusResponse, ) from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email @@ -620,6 +625,66 @@ async def get_project_upload_status( ) +@router.get("/{project_id}/upload-overview", response_model=UploadOverviewResponse) +async def get_project_upload_overview( + project_id: UUID, + session: dict[str, Any] = Depends(verify_session), +) -> UploadOverviewResponse | JSONResponse: + """B03 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. + + localStorage 기반 표시는 캐시를 지우거나 다른 PC로 가면 사라진다(2026-08-04 사용자 + 보고). 화면은 진입 시 이 응답을 정본으로 삼고 localStorage는 보조로만 쓴다. + """ + pool = get_db_pool() + try: + async with pool.acquire() as connection: + files = await list_project_input_files(connection, project_id) + sessions = await list_incomplete_upload_sessions(connection, project_id) + file_types, point_cloud_id = await get_project_input_readiness(connection, project_id) + async with connection.cursor(aiomysql.DictCursor) as cursor: + state = await get_workflow_state(cursor, str(project_id)) + stages = (state or {}).get("stages") or [] + analysis_complete = any( + int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE" + for stage in stages + ) + return UploadOverviewResponse( + files=[ + UploadOverviewFile( + input_file_id=int(row["id"]), + file_type=str(row["file_type"]), + original_filename=str(row["original_filename"]), + file_size_mb=float(row["file_size_mb"] or 0.0), + status=str(row["status"]), + uploaded_at=str(row["created_at"]) if row.get("created_at") else None, + ) + for row in files + ], + pending_sessions=[ + UploadOverviewSession( + upload_session_id=str(row["id"]), + original_filename=str(row["original_filename"]), + file_size_bytes=int(row["file_size_bytes"]), + total_chunks=int(row["total_chunks"]), + completed_chunks=int(row["completed_chunks"]), + progress_percent=round( + 100.0 * int(row["completed_chunks"]) / max(1, int(row["total_chunks"])), 1 + ), + updated_at=str(row["updated_at"]) if row.get("updated_at") else None, + ) + for row in sessions + ], + required_complete=_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None, + analysis_complete=analysis_complete, + ) + except Exception: + logger.exception("B03 업로드 현황 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 현황 조회 중 오류가 발생했습니다."}, + ) + + @router.get("/{project_id}/workflow-state") async def get_project_workflow_state(project_id: str): pool = get_db_pool() diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index e1338472..118e429f 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -104,3 +104,41 @@ class UploadStatusResponse(BaseModel): total_chunks: int completed_chunks: int completed_chunk_indexes: list[int] + + +class UploadOverviewFile(BaseModel): + """재접속 현황 — 업로드 완료된 입력 파일 한 건(서버 정본).""" + + input_file_id: int + file_type: str + original_filename: str + file_size_mb: float + status: str + uploaded_at: str | None = None + + +class UploadOverviewSession(BaseModel): + """재접속 현황 — 중단된 청크 업로드 세션 한 건(이어올리기 안내용).""" + + upload_session_id: str + original_filename: str + file_size_bytes: int + total_chunks: int + completed_chunks: int + progress_percent: float + updated_at: str | None = None + + +class UploadOverviewResponse(BaseModel): + """B03 재접속 시 업로드 현황 — localStorage가 아니라 이 응답이 정본이다. + + `required_complete`는 필수 파일(LAS/LAZ 1 + csv·prj·tfw)이 모두 업로드된 상태, + `analysis_complete`는 WF1 분석(stage 1)까지 끝난 상태다. 두 값이 모두 참이면 + 화면은 "완료" 배지를 띄우고, 완료 슬롯에 재업로드하면 교체 경고 모달을 띄운다. + """ + + status: str = "success" + files: list[UploadOverviewFile] + pending_sessions: list[UploadOverviewSession] + required_complete: bool + analysis_complete: bool diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py new file mode 100644 index 00000000..8d211d02 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -0,0 +1,148 @@ +"""B03 업로드 이후 자동 설계 체인 — WF1 확정 다음을 잇는다. + +WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어도 서버가 이어서 +① B05 기본 경로 계산·확정(계획노선 CSV 기반) ② B06 기본 횡단 설계 확정까지 +기본값으로 진행해 영구저장소에 남긴다(2026-08-04 사용자 확정). 이후 사용자가 +대시보드에서 B05/B06에 들어오면 저장본을 바로 로딩해 검토·수정만 하면 된다. + +원칙: +- **수동 이력 보호**: 프로젝트에 경로가 하나라도 있으면 체인을 건너뛴다 — 사용자가 + 이미 작업한 것을 자동 계산이 덮어쓰면 안 된다. +- **단계별 실패 격리**: 각 단계는 해당 라우터가 자기 workflow stage 전이(실패 기록)를 + 책임진다. 체인은 실패한 단계에서 멈추고 뒤 단계로 오류를 전파하지 않는다 — + 사용자는 그 페이지에서 수동으로 이어서 진행할 수 있다. +- 라우터 함수를 직접 호출한다(HTTP 재진입 없음). solve/confirm 엔드포인트는 인증 + 의존성이 없는 순수 함수 시그니처라 서버 내부 호출이 가능하다. +""" + +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi.responses import JSONResponse + +logger = logging.getLogger(__name__) + + +def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, float]] | None: + """계획노선 CSV를 읽어 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None. + + B04 `/planned-route` 조회와 같은 규칙 — CSV가 제 좌표계(crs_epsg)를 적어 두었고 + 프로젝트 좌표계와 다르면 한 번 옮긴다. + """ + from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import project_epsg_from_prj + from common_util.common_util_route_geometry import ( + find_planned_route_file, + read_planned_route_csv, + ) + + route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") + planned = read_planned_route_csv(route_file) if route_file else None + if planned is None or len(planned.vertices) < 2: + return None + target_epsg = project_epsg_from_prj(project_root) + points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices] + source_epsg = f"EPSG:{planned.epsg}" if planned.epsg else target_epsg + if source_epsg.upper() != target_epsg.upper(): + from pyproj import Transformer + + transformer = Transformer.from_crs(source_epsg, target_epsg, always_xy=True) + points = [transformer.transform(x, y) for x, y in points] + return [{"x": x, "y": y} for x, y in points] + + +async def run_auto_design_chain(project_id: UUID, surface_model_id: int | None = None) -> None: + """B05 기본 경로 계산·확정 → B06 기본 횡단 설계 확정을 기본값으로 이어 실행한다. + + WF1 자동 확정 직후 같은 백그라운드 태스크에서 호출된다. 어떤 단계가 실패해도 + 예외를 밖으로 던지지 않는다 — 로그와 각 단계의 workflow 상태 기록으로 남긴다. + """ + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from B05_wf2_Route.B05_wf2_Route_Repository import get_latest_route + from B05_wf2_Route.B05_wf2_Route_Router import confirm_latest_route, solve_route + from B05_wf2_Route.B05_wf2_Route_Schema import RoutePoint, RouteSolveRequest + from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router_Confirm import confirm_sections + from common_util.common_util_storage import resolve_stored_project_path + from common_util.common_util_surface_confirmation import surface_confirmation_defaults + from config.config_db import get_db_pool + + pool = get_db_pool() + try: + # 1) 수동 이력 보호 — 경로가 이미 있으면(사용자 작업 또는 이전 자동 실행) 건너뛴다. + async with pool.acquire() as connection: + existing = await get_latest_route(connection, project_id) + stored_path = await get_project_storage_relative_path(connection, project_id) + if existing: + logger.info( + "자동 설계 체인 건너뜀(기존 경로 있음): project_id=%s route_id=%s", + project_id, + existing.get("id"), + ) + return + + # 2) 계획노선 CSV → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다. + project_root = Path(resolve_stored_project_path(stored_path)) + points = _planned_route_points_in_project_crs(project_root) + if not points: + logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) + return + + # 3) B05 경로 계산 — WF1 자동 확정과 같은 config 기본값을 쓴다. + defaults = surface_confirmation_defaults() + request = RouteSolveRequest( + filter_key=str(defaults["source_filter"]), + method=str(defaults["method"]), + smooth=bool(defaults["smooth"]), + surface_model_id=surface_model_id, + bp=RoutePoint(**points[0]), + ep=RoutePoint(**points[-1]), + cp=[ + RoutePoint(**point, order=index) + for index, point in enumerate(points[1:-1], start=1) + ], + ) + solve_result: Any = await solve_route(project_id, request) + if isinstance(solve_result, JSONResponse): + logger.error( + "자동 설계 체인 중단(B05 경로 계산 실패): project_id=%s status=%s", + project_id, + solve_result.status_code, + ) + return + route_id = int(solve_result.route_id) + logger.info( + "자동 설계 체인 B05 경로 계산 완료: project_id=%s route_id=%s length=%.1fm", + project_id, + route_id, + float(solve_result.total_length_m or 0.0), + ) + + # 4) B05 경로 확정 — stage 2 완료 전이 포함. + confirm_result = await confirm_latest_route(project_id, None) + if isinstance(confirm_result, JSONResponse): + logger.error( + "자동 설계 체인 중단(B05 경로 확정 실패): project_id=%s status=%s", + project_id, + confirm_result.status_code, + ) + return + + # 5) B06 횡단 설계 확정 — 미지정 측점을 기본값으로 채워 저장, stage 3 완료 전이 포함. + sections_result = await confirm_sections(project_id, route_id, None) + if isinstance(sections_result, JSONResponse): + logger.error( + "자동 설계 체인 중단(B06 횡단 확정 실패): project_id=%s route_id=%s status=%s", + project_id, + route_id, + sections_result.status_code, + ) + return + logger.info( + "자동 설계 체인 완료(B05·B06 기본값 확정): project_id=%s route_id=%s", + project_id, + route_id, + ) + except Exception: + # 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다. + logger.exception("자동 설계 체인 실패: project_id=%s", project_id) diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 9444606f..ee82e056 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -18,6 +18,7 @@ from common_util.common_util_surface_confirmation import surface_confirmation_de from common_util.common_util_workflow_state import fail_stage, start_stage from config.config_db import get_db_pool from config.config_system import ( + AUTO_DESIGN_CHAIN_ENABLED, SEND_ANALYSIS_COMPLETION_EMAIL, SURFACE_MODEL_PRECOMPUTE, SURFACE_MODEL_SOURCE_FILTERS, @@ -98,6 +99,7 @@ async def trigger_wf1_analysis_and_email( auto_confirmation_error: str | None = None auto_confirmed = False + confirmed_model_id: int | None = None async with pool.acquire() as connection: await connection.begin() try: @@ -129,6 +131,7 @@ async def trigger_wf1_analysis_and_email( else: await confirm_surface_selection(connection, project_id, model_id, selection) auto_confirmed = True + confirmed_model_id = model_id await connection.commit() except Exception: await connection.rollback() @@ -152,6 +155,13 @@ async def trigger_wf1_analysis_and_email( to_email=str(project_info["user_email"]), analysis_result=analysis_result, ) + + # WF1 자동 확정까지 끝났으면 같은 백그라운드 태스크에서 B05 기본 경로 → B06 기본 + # 횡단 설계 체인을 잇는다(2026-08-04 사용자 확정). 체인은 실패를 스스로 격리한다. + if auto_confirmed and AUTO_DESIGN_CHAIN_ENABLED: + from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain + + await run_auto_design_chain(project_id, surface_model_id=confirmed_model_id) except Exception as exc: logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id) async with pool.acquire() as connection, connection.cursor() as cursor: diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 140360eb..025d2899 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -16,6 +16,7 @@ import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { checkWF1AnalysisStatus, createUploadSession, + fetchUploadOverview, finalizeUploadSession, uploadFileChunk, type UploadedFileResult, @@ -88,6 +89,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise { resumeBanner = document.createElement("div"); resumeBanner.className = "b03-file__resume"; + // 재접속 현황(서버 정본) 표시 — 전체 완료 배지와 중단 세션 안내가 여기 붙는다. + const overviewBanner = document.createElement("div"); + overviewBanner.className = "b03-file__overview"; + const template = createFileCardTemplate(); function selectedStates(): FileSlotState[] { @@ -146,8 +151,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const remove = card.querySelector(".b03-file__card-remove"); const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0; - if (fileName) fileName.textContent = state.file?.name ?? ""; - if (fileSize) fileSize.textContent = state.file ? formatBytes(state.file.size) : ""; + // 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다. + if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? ""; + if (fileSize) { + fileSize.textContent = state.file + ? formatBytes(state.file.size) + : state.serverUploaded + ? `${state.serverUploaded.sizeMb.toFixed(2)} MB` + : ""; + } if (progress) progress.style.width = `${percent}%`; if (progressBytes) { progressBytes.textContent = `${L("B03_File_Progress_Bytes")}: ${formatBytes( @@ -166,7 +178,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (remove) remove.hidden = !state.file; if (state.error) setCardState(slot, "failed"); - else if (!state.file) setCardState(slot, "empty"); + else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty"); else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus); updateUploadButton(); } @@ -195,7 +207,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return null; } - function assignFileToSlot(file: File, targetSlot?: FileSlot): void { + async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise { const extension = getExtension(file.name); const state = targetSlot ? slots.get(targetSlot) @@ -213,6 +225,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise { showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); return; } + // 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로 + // 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다. + if (state.serverUploaded) { + const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name); + if (!accepted) return; + } state.file = file; state.uploadSessionId = undefined; @@ -230,8 +248,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = L("B03_File_Error_Count"); return; } - for (const file of files) assignFileToSlot(file, targetSlot); - void detectPausedUploads(); + void (async () => { + for (const file of files) await assignFileToSlot(file, targetSlot); + await detectPausedUploads(); + })(); } function removeFile(slot: FileSlot): void { @@ -255,12 +275,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const selected = selectedStates(); if (selected.length === 0) return L("B03_File_Error_Required"); if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count"); + // 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 — + // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시). const missingRequired = Array.from(slots.values()).some( - (state) => state.isRequired && !state.file, + (state) => state.isRequired && !state.file && !state.serverUploaded, ); if (missingRequired) return L("B03_File_Error_RequiredSlots"); const lasState = slots.get("las_laz"); - if (!lasState?.file) return L("B03_File_Error_Las"); + if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las"); for (const state of selected) { if (state.error) return state.error; const validation = validateFileForSlot(state.file!, state); @@ -269,6 +291,98 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return null; } + /** + * 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의 + * 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true). + */ + function confirmReplaceUpload(slotLabel: string, fileName: string): Promise { + return new Promise((resolve) => { + const backdrop = document.createElement("div"); + backdrop.className = "b03-file__modal-backdrop"; + const modal = document.createElement("div"); + modal.className = "b03-file__modal"; + modal.setAttribute("role", "alertdialog"); + modal.setAttribute("aria-modal", "true"); + const title = document.createElement("strong"); + title.textContent = L("B03_File_Replace_Title"); + const message = document.createElement("p"); + message.textContent = `${slotLabel}: ${fileName}\n${L("B03_File_Replace_Message")}`; + const actions = document.createElement("div"); + actions.className = "b03-file__modal-actions"; + const done = (accepted: boolean): void => { + backdrop.remove(); + resolve(accepted); + }; + const cancel = createButton({ + label: L("B03_File_Replace_Cancel"), + variant: "ghost", + onClick: () => done(false), + }); + const accept = createButton({ + label: L("B03_File_Replace_Confirm"), + variant: "filled", + onClick: () => done(true), + }); + actions.append(cancel, accept); + modal.append(title, message, actions); + backdrop.append(modal); + backdrop.addEventListener("click", (event) => { + if (event.target === backdrop) done(false); + }); + document.body.append(backdrop); + accept.focus(); + }); + } + + /** + * 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크 + * 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다. + * localStorage 기반 표시(restoreB03ProjectState)는 보조로 유지된다. + */ + async function applyUploadOverview(): Promise { + if (!activeProjectId) return; + overviewBanner.replaceChildren(); + overviewBanner.classList.remove("is-visible"); + try { + const overview = await fetchUploadOverview(activeProjectId); + // 확장자 → 슬롯 매핑으로 서버 파일을 카드에 얹는다(같은 슬롯이면 최신 것 우선 — 목록이 + // id 오름차순이므로 마지막 것이 남는다). + for (const state of slots.values()) state.serverUploaded = undefined; + for (const file of overview.files) { + const extension = `.${file.file_type.toLowerCase()}`; + const state = Array.from(slots.values()).find((candidate) => + candidate.extensions.includes(extension), + ); + if (state) { + state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb }; + } + } + for (const slot of slots.keys()) renderSlot(slot); + + const notes: HTMLElement[] = []; + if (overview.required_complete && overview.analysis_complete) { + const complete = document.createElement("p"); + complete.className = "b03-file__overview-complete"; + complete.textContent = L("B03_File_Overview_Complete"); + notes.push(complete); + } + for (const session of overview.pending_sessions) { + const pending = document.createElement("p"); + pending.className = "b03-file__overview-pending"; + pending.textContent = + `${session.original_filename} — ${session.progress_percent}% ` + + L("B03_File_Overview_Pending"); + notes.push(pending); + } + if (notes.length) { + overviewBanner.append(...notes); + overviewBanner.classList.add("is-visible"); + } + } catch { + // 현황 조회 실패는 업로드 자체를 막지 않는다 — localStorage 보조 표시로만 동작. + } + } + function createFileCard(state: FileSlotState): HTMLElement { const fragment = template.content.cloneNode(true) as DocumentFragment; const card = fragment.querySelector(".b03-file__card"); @@ -546,7 +660,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const uploadControlPanel = document.createElement("div"); uploadControlPanel.className = "b03-file__control-panel"; - uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList); + uploadControlPanel.append( + subtitle, + overviewBanner, + dropzone, + resumeBanner, + pageError, + uploadButton, + resultList, + ); const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]); routeGroup.classList.add("b03-file__group--route"); @@ -587,6 +709,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { for (const slot of slots.keys()) renderSlot(slot); void registerB03ServiceWorker(); + void applyUploadOverview(); void detectPausedUploads(); if (activeProjectId) { restoreB03ProjectState({ diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 9ac5af96..8a11dc90 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -111,6 +111,69 @@ display: flex; } +/* 재접속 현황(서버 정본) 배너 — 완료 안내와 중단 세션 이어올리기 안내를 담는다. */ +.b03-file__overview { + display: none; + flex-direction: column; + gap: var(--spacing-4); + margin-top: var(--spacing-8); + padding: var(--spacing-12) var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards, 8px); + background: var(--color-surface); + font-size: var(--text-body-sm, 14px); +} + +.b03-file__overview.is-visible { + display: flex; +} + +.b03-file__overview-complete { + margin: 0; + color: var(--color-success, #3fbb6f); + font-weight: 600; +} + +.b03-file__overview-pending { + margin: 0; + color: var(--color-text-secondary); +} + +/* 완료 슬롯 재업로드 확인 모달 — 페이지 위를 덮는 단순 확인창. */ +.b03-file__modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 45%); +} + +.b03-file__modal { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + max-width: 420px; + padding: var(--spacing-24); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards, 8px); + background: var(--color-surface-raised); + box-shadow: 0 12px 32px rgb(0 0 0 / 30%); +} + +.b03-file__modal p { + margin: 0; + color: var(--color-text-secondary); + white-space: pre-line; +} + +.b03-file__modal-actions { + display: flex; + gap: var(--spacing-8); + justify-content: flex-end; +} + .b03-file__cards-grid { display: grid; grid-template-columns: 1fr; diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index ad2a509c..97ea273a 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -19,6 +19,12 @@ export interface FileSlotState extends SlotConfig { speedMbs: number; etaSeconds: number | null; error?: string; + /** + * 서버(DB `input_files`)에 이미 업로드 완료된 파일 정보 — 재접속 현황의 정본. + * 로컬 파일을 새로 고르지 않아도 카드에 완료 상태로 표시하고, 이 슬롯에 새 파일을 + * 올리면 교체 확인 모달을 띄우는 근거가 된다(2026-08-04 사용자 지시). + */ + serverUploaded?: { name: string; sizeMb: number }; } export interface StoredUploadSession { diff --git a/config/config_system.py b/config/config_system.py index b8fe511f..c1a1be18 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -55,6 +55,9 @@ MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) SEND_ANALYSIS_COMPLETION_EMAIL = ( os.getenv("SEND_ANALYSIS_COMPLETION_EMAIL", "True").lower() == "true" ) +# WF1 자동 확정 후 B05 기본 경로 → B06 기본 횡단 설계까지 서버가 이어서 계산·확정한다 +# (2026-08-04 사용자 확정). 프로젝트에 경로가 이미 있으면 체인은 스스로 건너뛴다. +AUTO_DESIGN_CHAIN_ENABLED = os.getenv("AUTO_DESIGN_CHAIN_ENABLED", "True").lower() == "true" # ───────────────────────────────────────────────────────────────────────── # 5. 지형 분석 알고리즘 파라미터 diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 8c42a924..397cd933 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -241,6 +241,21 @@ export const ui_locales_b1 = { B03_File_Restore_State: ["저장된 업로드/분석 상태 복구", "Restored upload/analysis state"], B03_File_Resume_Button: ["업로드 재개", "Resume upload"], B03_File_New_Button: ["새 파일로 시작", "Start new file"], + B03_File_Overview_Complete: [ + "필수 파일 업로드와 지표면 분석이 모두 완료된 프로젝트입니다. 파일을 다시 올리면 기존 결과가 교체됩니다.", + "All required files are uploaded and surface analysis is complete. Re-uploading replaces existing results.", + ], + B03_File_Overview_Pending: [ + "지점에서 중단된 업로드가 있습니다 — 같은 파일을 다시 선택하면 이어서 올립니다.", + "upload was interrupted — reselect the same file to resume.", + ], + B03_File_Replace_Title: ["기존 파일 교체 확인", "Confirm file replacement"], + B03_File_Replace_Message: [ + "이 슬롯에는 이미 업로드된 파일이 있습니다. 새 파일을 올리면 기존 파일과 그에 따른 분석 결과(지표면 모델 등)가 교체됩니다. 계속하시겠습니까?", + "This slot already has an uploaded file. Uploading a new one replaces the existing file and derived analysis results (surface model, etc.). Continue?", + ], + B03_File_Replace_Confirm: ["교체하고 계속", "Replace and continue"], + B03_File_Replace_Cancel: ["취소", "Cancel"], B03_File_ServiceWorker_Ready: [ "백그라운드 업로드 준비가 완료되었습니다.", "Background upload is ready.",