feat(B03): 재접속 업로드 현황(10) + B05·B06 자동 설계 체인(9)

10. 재접속 현황·완료 표시·재업로드 경고:
- GET /projects/{id}/upload-overview 신설 — 완료 파일 목록(input_files 정본),
  중단 청크 세션(진행률), 필수 파일·WF1 분석 완료 여부
- 진입 시 서버 정본으로 슬롯 카드 표시(serverUploaded), localStorage는 보조로 강등
- 파일 재선택 전에도 중단 세션 이어올리기 안내 배너
- 전체 완료 배지 + 완료 슬롯 재업로드 시 교체 확인 모달(승인 시에만 진행)
- 필수 슬롯 검증: 서버 업로드분 있으면 충족 — 단일 파일 교체 업로드 허용

9. 자동 설계 체인 연장 (B03_FileInput_Service_Chain.py):
- WF1 자동 확정 후 같은 백그라운드 태스크에서 ① 계획노선 CSV 기반 B05 기본 경로
  계산(solve) ② 경로 확정(stage 2) ③ B06 기본 횡단 설계 확정(stage 3)까지 진행
- 수동 이력 보호: 프로젝트에 경로가 이미 있으면 건너뜀
- 단계별 실패 격리: 실패 단계에서 멈추고 로그·workflow 상태로만 기록
- AUTO_DESIGN_CHAIN_ENABLED config 플래그(기본 True)

typecheck·ruff·B03 unittest(7건) 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:09:15 +09:00
co-authored by Claude Opus 5
parent 6080d122bb
commit e2f6a101b6
11 changed files with 564 additions and 9 deletions
+36
View File
@@ -146,6 +146,42 @@ export async function fetchUploadStatus(
return await readJsonOrThrow<UploadStatusResponse>(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<UploadOverviewResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
method: "GET",
credentials: "include",
});
return await readJsonOrThrow<UploadOverviewResponse>(response);
}
export interface WF1AnalysisStatus {
project_id: string;
status: "pending" | "in_progress" | "completed" | "failed";
+48
View File
@@ -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]
+65
View File
@@ -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()
+38
View File
@@ -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
@@ -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)
@@ -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:
+132 -9
View File
@@ -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<void> {
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<void> {
const remove = card.querySelector<HTMLButtonElement>(".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<void> {
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<void> {
return null;
}
function assignFileToSlot(file: File, targetSlot?: FileSlot): void {
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
const extension = getExtension(file.name);
const state = targetSlot
? slots.get(targetSlot)
@@ -213,6 +225,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
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<void> {
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<void> {
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<void> {
return null;
}
/**
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
*/
function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
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<void> {
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<HTMLElement>(".b03-file__card");
@@ -546,7 +660,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
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<void> {
for (const slot of slots.keys()) renderSlot(slot);
void registerB03ServiceWorker();
void applyUploadOverview();
void detectPausedUploads();
if (activeProjectId) {
restoreB03ProjectState({
+63
View File
@@ -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;
@@ -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 {
+3
View File
@@ -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. 지형 분석 알고리즘 파라미터
+15
View File
@@ -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.",