From b525a13670bb0b70482080e42525977331925bdb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 18:14:18 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(B09):=20=EC=A2=81=EC=9D=80=20=ED=8F=AD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=91=9C=EA=B0=80=20=EB=B0=94=EA=B9=A5=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=84=20=EB=B0=80=EC=96=B4=EB=82=B4=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측(창 620) — .b09-main 이 1,190px 밀려 나감. 원인이 세 겹이었음. - .b09-main · 표를 감싸는 body 칸에 min-width: 0 이 없었음. flex 자식 기본 최소폭이 auto 라 안쪽 표의 최소폭(칸이 nowrap)을 그대로 물었음. - ⚠ 진짜 원인은 그 아래였음 — .b09-sheet 가 **감싸는 칸(div)에 붙는 자리와 표(table)에 바로 붙는 자리**가 둘 다 있음(내역서·자재대는 표에 직접). 표에는 overflow 가 안 먹어 표 폭만큼 바깥이 밀렸음. table.b09-sheet 를 블록으로 바꿔 제 안에서 가로로 넘어가게 함. - 주석에 백틱을 쓰면 템플릿 문자열이 끊김 — 그 사실을 주석에 남김(한 번 겪음). 실측: 다섯 탭(공사원가계산서·설계내역서·일위대가·단가산출근거·관급·사급) × 창 1400·620 전부 몸통 가로밀림 없음·넘침 0px, 표만 제 안에서 스크롤. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_UI_Page.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 700db411..36ffc8a4 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -160,7 +160,12 @@ function injectStyles(): void { .b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); } .b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } -.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; } +/* ⚠ min-width: 0 이 빠지면 **표가 넓은 만큼 이 칸이 통째로 밀려 나간다**(2026-09-08 실측: + 창 620 에서 1,190px 넘침). flex 자식의 기본 최소폭이 auto 라 안쪽 표의 최소폭 + (칸이 nowrap)을 그대로 물기 때문이다. 0 으로 끊어야 아래 .b09-sheet 의 + overflow: auto 가 제 몫을 해서 **표만 제 안에서 가로로 넘어간다.** + ⚠ 이 주석 안에 백틱을 쓰지 말 것 — 이 블록은 템플릿 문자열 안이라 거기서 끊긴다. */ +.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; min-width: 0; } .b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; } .b09-tab { font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer; @@ -169,7 +174,12 @@ function injectStyles(): void { .b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); } .b09-tab:disabled { cursor: not-allowed; opacity: .55; } -.b09-sheet { overflow: auto; min-height: 0; flex: 1; } +.b09-sheet { overflow: auto; min-height: 0; min-width: 0; flex: 1; } +/* ⚠ 이 클래스가 **감싸는 칸(div)에 붙는 자리와 표(table)에 바로 붙는 자리**가 둘 다 있다 + (내역서·자재대는 표에 직접 붙인다). 표는 그대로 두면 overflow 가 안 먹어 **표 폭만큼 + 바깥 칸을 밀어낸다**(2026-09-08 실측: 창 620 에서 1,190px). 블록으로 바꾸면 제 안에서 + 가로로 넘어가고 바깥은 안 밀린다. 표 안쪽(thead·tbody)의 칸 배치는 그대로다. */ +table.b09-sheet { display: block; overflow-x: auto; max-width: 100%; } .b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); } .b09-sheet th, .b09-sheet td { border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right; @@ -775,6 +785,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise { const body = document.createElement("div"); body.style.flex = "1"; body.style.minHeight = "0"; + // ⚠ 세로와 마찬가지로 **가로도 0 으로 끊어야** 한다(2026-09-08 실측). 안 끊으면 이 칸이 + // 안쪽 표의 최소폭(칸이 nowrap)을 그대로 물어 창 620 에서 1,190px 밀려 나갔다. + // 0 이면 아래 .b09-sheet 의 overflow:auto 가 살아나 **표만 제 안에서 가로로 넘어간다.** + body.style.minWidth = "0"; body.style.display = "flex"; body.style.flexDirection = "column"; From 09e1c983ae4aad39387cf000efc8540b17317b75 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 18:14:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(B03):=20=EC=83=88=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=88=EA=B8=B0=20=EC=A0=84=EC=97=90=20=E3=80=8C?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B3=A0=20=EC=A7=84=ED=96=89=ED=95=A0?= =?UTF-8?q?=EA=B9=8C=E3=80=8D=20=ED=99=95=EC=9D=B8=20=ED=95=9C=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업로드 하나가 그 프로젝트의 설계 산출물·초기값 스냅숏을 되돌릴 수 없게 지운다. 창 넷이 한 프로젝트를 볼 수 있어 말없이 지우면 남의 작업이 사라진다. - `describe_existing_outputs`: 지워질 것을 사람 말로 낸다(전처리·노선·횡단·도면· 수량·원가·초기값). 빈 폴더는 세지 않는다. - 지울 것이 있는데 확인이 없으면 409 `confirm_required` — 무엇이 지워지는지 함께 준다. - 세 갈래(직행·청크·보관함 연결) 전부에 `confirm_replace` 를 붙임. - ⚠ 기본값은 확인 켬(True) — 자동 절차(체인·스크립트)는 물음에 안 걸린다. 사람이 올리는 라우터만 False 로 불러 확인을 받는다. - 화면: 409 를 받으면 「되돌릴 수 없습니다」와 지워질 목록을 보이고 한 번 묻는다. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Api_Fetch.ts | 32 ++++++++- B03_FileInput/B03_FileInput_Router.py | 11 ++- B03_FileInput/B03_FileInput_Router_Chunks.py | 10 +++ B03_FileInput/B03_FileInput_Router_Helpers.py | 67 +++++++++++++++++++ B03_FileInput/B03_FileInput_Router_Temp.py | 11 +++ B03_FileInput/B03_FileInput_Schema.py | 6 ++ B03_FileInput/B03_FileInput_UI_Upload.ts | 60 +++++++++++++---- 7 files changed, 181 insertions(+), 16 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 4768fecd..8f693855 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -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(response: Response): Promise { - 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(response: Response): Promise { export async function uploadProjectFiles( projectId: string, files: readonly File[], + confirmReplace = false, ): Promise { 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 { 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(response); @@ -131,6 +159,7 @@ export async function finalizeUploadSession( completeUpload: boolean, fingerprint?: string | null, lasFree = false, + confirmReplace = false, ): Promise { 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(response); diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 76956605..fcf2511f 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -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: diff --git a/B03_FileInput/B03_FileInput_Router_Chunks.py b/B03_FileInput/B03_FileInput_Router_Chunks.py index 0ded7774..04eecf1c 100644 --- a/B03_FileInput/B03_FileInput_Router_Chunks.py +++ b/B03_FileInput/B03_FileInput_Router_Chunks.py @@ -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: diff --git a/B03_FileInput/B03_FileInput_Router_Helpers.py b/B03_FileInput/B03_FileInput_Router_Helpers.py index 2251fc9c..b088a992 100644 --- a/B03_FileInput/B03_FileInput_Router_Helpers.py +++ b/B03_FileInput/B03_FileInput_Router_Helpers.py @@ -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) diff --git a/B03_FileInput/B03_FileInput_Router_Temp.py b/B03_FileInput/B03_FileInput_Router_Temp.py index e962669b..1551e903 100644 --- a/B03_FileInput/B03_FileInput_Router_Temp.py +++ b/B03_FileInput/B03_FileInput_Router_Temp.py @@ -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: diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 5211862d..64dc3701 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -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): diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index b3bceb42..a64cfb98 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -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(run: (confirmReplace: boolean) => Promise): Promise { + 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( - projectId, - file, - chunkSizeBytes, - fingerprint, - completeUpload, - lasFree, + 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( - projectId, - session, - totalChunks, - completeUpload, - fingerprint, - lasFree, + const response = await askReplaceOutputs((confirmReplace) => + finalizeUploadSession( + projectId, + session, + totalChunks, + completeUpload, + fingerprint, + lasFree, + confirmReplace, + ), ); localStorage.removeItem(storageKey); saveB03UploadedFile(projectId, { From 35dc09f5dda95f28f12b3fde1e6215a806463886 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 18:19:21 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(B03):=20=EC=B2=AD=ED=81=AC=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=EC=97=90=20=ED=99=95=EC=9D=B8=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?import=20=EB=88=84=EB=9D=BD=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실사용 확인에서 청크 세션 생성이 NameError 로 500 이 났다. 세 갈래가 확인 단계 이름을 다 들고 있는지 지키는 시험을 추가. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Router_Chunks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/B03_FileInput/B03_FileInput_Router_Chunks.py b/B03_FileInput/B03_FileInput_Router_Chunks.py index 04eecf1c..5cc09368 100644 --- a/B03_FileInput/B03_FileInput_Router_Chunks.py +++ b/B03_FileInput/B03_FileInput_Router_Chunks.py @@ -35,8 +35,10 @@ from B03_FileInput.B03_FileInput_Repository import ( from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response from B03_FileInput.B03_FileInput_Router_Helpers import ( _ANALYSIS_RUNNING_MESSAGE, + OutputsWouldBeDiscarded, _already_uploaded, _complete_file_input_if_ready, + _confirm_replace_response, _schedule_background_task, _total_chunks, _write_stage_metadata,