feat(B03): 새 자료로 갈기 전에 「지우고 진행할까」 확인 한 단계 신설
업로드 하나가 그 프로젝트의 설계 산출물·초기값 스냅숏을 되돌릴 수 없게 지운다. 창 넷이 한 프로젝트를 볼 수 있어 말없이 지우면 남의 작업이 사라진다. - `describe_existing_outputs`: 지워질 것을 사람 말로 낸다(전처리·노선·횡단·도면· 수량·원가·초기값). 빈 폴더는 세지 않는다. - 지울 것이 있는데 확인이 없으면 409 `confirm_required` — 무엇이 지워지는지 함께 준다. - 세 갈래(직행·청크·보관함 연결) 전부에 `confirm_replace` 를 붙임. - ⚠ 기본값은 확인 켬(True) — 자동 절차(체인·스크립트)는 물음에 안 걸린다. 사람이 올리는 라우터만 False 로 불러 확인을 받는다. - 화면: 409 를 받으면 「되돌릴 수 없습니다」와 지워질 목록을 보이고 한 번 묻는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,9 +51,33 @@ export interface UploadStatusResponse {
|
||||
completed_chunk_indexes: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 「지우고 진행할까?」를 물어야 하는 상태(409). 서버가 무엇이 지워지는지 함께 준다.
|
||||
* 되돌릴 수 없는 삭제라 사람이 한 번 보고 정한다(2026-09-08 사용자 지시).
|
||||
*/
|
||||
export class ReplaceOutputsConfirmRequired extends Error {
|
||||
readonly targets: string[];
|
||||
|
||||
constructor(message: string, targets: string[]) {
|
||||
super(message);
|
||||
this.name = "ReplaceOutputsConfirmRequired";
|
||||
this.targets = targets;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonOrThrow<T>(response: Response): Promise<T> {
|
||||
const payload = (await response.json()) as T & { message?: string };
|
||||
const payload = (await response.json()) as T & {
|
||||
message?: string;
|
||||
confirm?: string;
|
||||
targets?: string[];
|
||||
};
|
||||
if (!response.ok) {
|
||||
if (response.status === 409 && payload.confirm === "replace_outputs") {
|
||||
throw new ReplaceOutputsConfirmRequired(
|
||||
payload.message ?? "이미 만들어 둔 결과가 지워집니다.",
|
||||
payload.targets ?? [],
|
||||
);
|
||||
}
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
@@ -62,9 +86,11 @@ async function readJsonOrThrow<T>(response: Response): Promise<T> {
|
||||
export async function uploadProjectFiles(
|
||||
projectId: string,
|
||||
files: readonly File[],
|
||||
confirmReplace = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append("files", file, file.name);
|
||||
if (confirmReplace) formData.append("confirm_replace", "true");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
@@ -88,6 +114,7 @@ export async function createUploadSession(
|
||||
fingerprint?: string | null,
|
||||
completeUpload = false,
|
||||
lasFree = false,
|
||||
confirmReplace = false,
|
||||
): Promise<ChunkSessionCreateResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
|
||||
method: "POST",
|
||||
@@ -100,6 +127,7 @@ export async function createUploadSession(
|
||||
fingerprint: fingerprint ?? null,
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
confirm_replace: confirmReplace,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
|
||||
@@ -131,6 +159,7 @@ export async function finalizeUploadSession(
|
||||
completeUpload: boolean,
|
||||
fingerprint?: string | null,
|
||||
lasFree = false,
|
||||
confirmReplace = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
|
||||
method: "POST",
|
||||
@@ -142,6 +171,7 @@ export async function finalizeUploadSession(
|
||||
complete_upload: completeUpload,
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
confirm_replace: confirmReplace,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
|
||||
@@ -59,6 +59,10 @@ from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
_SHAPEFILE_REQUIRED_TYPES as _SHAPEFILE_REQUIRED_TYPES,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
OutputsWouldBeDiscarded,
|
||||
_confirm_replace_response,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
_already_uploaded as _already_uploaded,
|
||||
)
|
||||
@@ -132,6 +136,7 @@ async def upload_project_files(
|
||||
project_id: UUID,
|
||||
files: list[UploadFile] = File(...),
|
||||
las_free: bool = Form(False),
|
||||
confirm_replace: bool = Form(False),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> FileUploadResponse | JSONResponse:
|
||||
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
||||
@@ -258,7 +263,7 @@ async def upload_project_files(
|
||||
)
|
||||
)
|
||||
point_cloud_input_id = await _complete_file_input_if_ready(
|
||||
connection, project_id, las_free
|
||||
connection, project_id, las_free, confirm_replace
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -282,6 +287,10 @@ async def upload_project_files(
|
||||
task_name=f"b04-preprocess-auto-{project_id}",
|
||||
)
|
||||
return FileUploadResponse(project_id=str(project_id), files=results)
|
||||
except OutputsWouldBeDiscarded as exc:
|
||||
for saved_path in saved_paths:
|
||||
saved_path.unlink(missing_ok=True)
|
||||
return _confirm_replace_response(exc)
|
||||
except LookupError as exc:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
except (OSError, ValueError) as exc:
|
||||
|
||||
@@ -110,6 +110,7 @@ async def create_project_upload_session(
|
||||
connection,
|
||||
project_id,
|
||||
payload.las_free,
|
||||
payload.confirm_replace,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -144,6 +145,8 @@ async def create_project_upload_session(
|
||||
chunk_size_bytes=chunk_size_bytes,
|
||||
total_chunks=total_chunks,
|
||||
)
|
||||
except OutputsWouldBeDiscarded as exc:
|
||||
return _confirm_replace_response(exc)
|
||||
except LookupError as exc:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
except (OSError, ValueError) as exc:
|
||||
@@ -202,6 +205,8 @@ async def upload_project_chunk(
|
||||
total_chunks=int(upload_session["total_chunks"]),
|
||||
chunk_hash=chunk_hash,
|
||||
)
|
||||
except OutputsWouldBeDiscarded as exc:
|
||||
return _confirm_replace_response(exc)
|
||||
except LookupError as exc:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
except (OSError, ValueError) as exc:
|
||||
@@ -300,6 +305,7 @@ async def finalize_project_upload(
|
||||
connection,
|
||||
project_id,
|
||||
payload.las_free,
|
||||
payload.confirm_replace,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -329,6 +335,8 @@ async def finalize_project_upload(
|
||||
task_name=f"b04-preprocess-auto-{project_id}",
|
||||
)
|
||||
return FileUploadResponse(project_id=str(project_id), files=[result])
|
||||
except OutputsWouldBeDiscarded as exc:
|
||||
return _confirm_replace_response(exc)
|
||||
except LookupError as exc:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
except (OSError, ValueError) as exc:
|
||||
@@ -373,6 +381,8 @@ async def get_project_upload_status(
|
||||
completed_chunks=len(completed_indexes),
|
||||
completed_chunk_indexes=completed_indexes,
|
||||
)
|
||||
except OutputsWouldBeDiscarded as exc:
|
||||
return _confirm_replace_response(exc)
|
||||
except LookupError as exc:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
except (OSError, ValueError) as exc:
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Email import (
|
||||
send_file_upload_complete_email,
|
||||
@@ -40,6 +41,61 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 새 자료를 올려 주세요."
|
||||
|
||||
# 새 자료를 받으면 옛 산출물을 지운다 — 지워질 것이 있으면 **먼저 묻는다**(2026-09-08).
|
||||
# 창 넷이 한 프로젝트를 함께 볼 수 있어, 말없이 지우면 남이 며칠 만든 설계가 사라진다.
|
||||
# 되돌릴 길이 없다(초기값 스냅숏까지 함께 버린다).
|
||||
_OUTPUT_STAGE_LABELS: tuple[tuple[str, str], ...] = (
|
||||
("B04_PreProcess", "지표면·전처리 결과"),
|
||||
("B05_Profile", "노선·종단 설계"),
|
||||
("B06_Section", "횡단 설계"),
|
||||
("B07_DesignDetail", "도면"),
|
||||
("B08_Quantity", "수량 산출"),
|
||||
("B09_Estimation", "원가·내역"),
|
||||
)
|
||||
|
||||
|
||||
class OutputsWouldBeDiscarded(Exception):
|
||||
"""이미 있는 설계 산출물을 지워야 새 자료를 받을 수 있는 상태.
|
||||
|
||||
라우터가 409 로 돌려주고, 사람이 「지우고 진행」을 고르면 `confirm_replace=True`
|
||||
로 다시 들어온다. 자동 절차는 이 예외를 만들지 않는다 — 부를 때 확인을 켜 준다.
|
||||
"""
|
||||
|
||||
def __init__(self, targets: list[str]) -> None:
|
||||
self.targets = targets
|
||||
super().__init__("설계 산출물이 있어 확인이 필요합니다.")
|
||||
|
||||
|
||||
def _confirm_replace_response(exc: "OutputsWouldBeDiscarded") -> JSONResponse:
|
||||
"""「지우고 진행할까?」를 사람에게 묻는 409 — 무엇이 지워지는지 함께 알린다."""
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"status": "confirm_required",
|
||||
"confirm": "replace_outputs",
|
||||
"message": (
|
||||
"이 프로젝트에는 이미 만들어 둔 결과가 있습니다. 새 자료로 갈면 "
|
||||
"아래가 지워지고 **되돌릴 수 없습니다**: " + " · ".join(exc.targets)
|
||||
),
|
||||
"targets": exc.targets,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def describe_existing_outputs(project_root: Path) -> list[str]:
|
||||
"""새 자료를 받으면 **지워질 것**의 이름을 사람 말로 늘어놓는다. 없으면 빈 목록."""
|
||||
targets: list[str] = []
|
||||
for stage, label in _OUTPUT_STAGE_LABELS:
|
||||
stage_root = project_root / stage
|
||||
if not stage_root.is_dir():
|
||||
continue
|
||||
if any(entry.is_file() for entry in stage_root.rglob("*")):
|
||||
targets.append(label)
|
||||
if (project_root / "initial_snapshot").is_dir():
|
||||
targets.append("초기값 스냅숏(되돌리기의 기준)")
|
||||
return targets
|
||||
|
||||
|
||||
_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"})
|
||||
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
|
||||
# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile).
|
||||
@@ -149,7 +205,13 @@ async def _complete_file_input_if_ready(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
las_free: bool = False,
|
||||
confirm_replace: bool = True,
|
||||
) -> int:
|
||||
"""새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다.
|
||||
|
||||
기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다.
|
||||
사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시).
|
||||
"""
|
||||
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
|
||||
connection, project_id
|
||||
)
|
||||
@@ -172,6 +234,11 @@ async def _complete_file_input_if_ready(
|
||||
gap_message = merge_gap_error(terrain_paths)
|
||||
if gap_message:
|
||||
raise ValueError(gap_message)
|
||||
# ⚠ 여기서부터는 되돌릴 수 없다 — 아래 셋이 파일까지 지운다. 지울 것이 있으면 먼저 묻는다.
|
||||
if not confirm_replace:
|
||||
targets = describe_existing_outputs(project_root)
|
||||
if targets:
|
||||
raise OutputsWouldBeDiscarded(targets)
|
||||
clear_designing(project_root)
|
||||
discard_initial_snapshot(project_root)
|
||||
await purge_project_outputs(connection, str(project_id), project_root)
|
||||
|
||||
@@ -86,6 +86,11 @@ from B03_FileInput.B03_FileInput_Schema_Temp import (
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email
|
||||
from common_util.common_util_auth import verify_session
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
OutputsWouldBeDiscarded,
|
||||
_confirm_replace_response,
|
||||
describe_existing_outputs,
|
||||
)
|
||||
from common_util.common_util_project_reset import purge_project_outputs
|
||||
from common_util.common_util_storage import (
|
||||
resolve_stored_project_path,
|
||||
@@ -359,6 +364,7 @@ async def upload_batch_files(
|
||||
async def attach_temp_batch(
|
||||
project_id: UUID,
|
||||
batch_id: str,
|
||||
confirm_replace: bool = False,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> TempBatchAttachResponse | JSONResponse:
|
||||
"""보관함 자료를 프로젝트 영구저장소로 옮기고 초기 분석을 시작한다.
|
||||
@@ -403,6 +409,11 @@ async def attach_temp_batch(
|
||||
|
||||
# 자료가 갈리므로 옛 계산 결과(파일 + DB)를 먼저 지운다 — 남겨 두면 아직 다시
|
||||
# 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
|
||||
# ⚠ 되돌릴 수 없다 — 지울 것이 있으면 먼저 묻는다(2026-09-08).
|
||||
if not confirm_replace:
|
||||
targets = describe_existing_outputs(project_root)
|
||||
if targets:
|
||||
return _confirm_replace_response(OutputsWouldBeDiscarded(targets))
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
|
||||
@@ -62,6 +62,9 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
|
||||
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
|
||||
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
|
||||
fingerprint: str | None = Field(default=None, max_length=128)
|
||||
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
|
||||
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
|
||||
confirm_replace: bool = False
|
||||
|
||||
|
||||
class ChunkSessionCreateResponse(BaseModel):
|
||||
@@ -103,6 +106,9 @@ class UploadFinalizeRequest(BaseModel):
|
||||
# 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이
|
||||
# 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택).
|
||||
fingerprint: str | None = Field(default=None, max_length=128)
|
||||
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
|
||||
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
|
||||
confirm_replace: bool = False
|
||||
|
||||
|
||||
class UploadStatusResponse(BaseModel):
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
checkWF1AnalysisStatus,
|
||||
createUploadSession,
|
||||
finalizeUploadSession,
|
||||
ReplaceOutputsConfirmRequired,
|
||||
uploadFileChunk,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
@@ -77,6 +78,31 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi
|
||||
* 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다.
|
||||
* 진행 상황은 `onProgress`로만 알린다 — 갱신 주기는 config 값으로 제한한다.
|
||||
*/
|
||||
/**
|
||||
* 「이미 만들어 둔 결과가 지워집니다」를 사람에게 한 번 묻는다.
|
||||
* 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시).
|
||||
* 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다.
|
||||
*/
|
||||
async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run(false);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error;
|
||||
const detail = error.targets.length
|
||||
? `
|
||||
|
||||
지워지는 것: ${error.targets.join(" · ")}`
|
||||
: "";
|
||||
const agreed = window.confirm(
|
||||
`${error.message}${detail}
|
||||
|
||||
되돌릴 수 없습니다. 계속할까요?`,
|
||||
);
|
||||
if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다.");
|
||||
return await run(true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadOneFile(
|
||||
projectId: string,
|
||||
state: FileSlotState,
|
||||
@@ -97,13 +123,16 @@ export async function uploadOneFile(
|
||||
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
|
||||
let session = state.uploadSessionId;
|
||||
if (!session) {
|
||||
const created = await createUploadSession(
|
||||
const created = await askReplaceOutputs((confirmReplace) =>
|
||||
createUploadSession(
|
||||
projectId,
|
||||
file,
|
||||
chunkSizeBytes,
|
||||
fingerprint,
|
||||
completeUpload,
|
||||
lasFree,
|
||||
confirmReplace,
|
||||
),
|
||||
);
|
||||
if (created.already_uploaded) {
|
||||
state.progressBytes = file.size;
|
||||
@@ -155,13 +184,16 @@ export async function uploadOneFile(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await finalizeUploadSession(
|
||||
const response = await askReplaceOutputs((confirmReplace) =>
|
||||
finalizeUploadSession(
|
||||
projectId,
|
||||
session,
|
||||
totalChunks,
|
||||
completeUpload,
|
||||
fingerprint,
|
||||
lasFree,
|
||||
confirmReplace,
|
||||
),
|
||||
);
|
||||
localStorage.removeItem(storageKey);
|
||||
saveB03UploadedFile(projectId, {
|
||||
|
||||
Reference in New Issue
Block a user