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:
@@ -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";
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. 지형 분석 알고리즘 파라미터
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user