Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-09-08 18:23:47 +09:00
8 changed files with 199 additions and 18 deletions
+31 -1
View File
@@ -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);
+10 -1
View File
@@ -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:
@@ -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,
@@ -110,6 +112,7 @@ async def create_project_upload_session(
connection,
project_id,
payload.las_free,
payload.confirm_replace,
)
await connection.commit()
except Exception:
@@ -144,6 +147,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 +207,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 +307,7 @@ async def finalize_project_upload(
connection,
project_id,
payload.las_free,
payload.confirm_replace,
)
await connection.commit()
except Exception:
@@ -329,6 +337,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 +383,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:
+6
View File
@@ -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):
+46 -14
View File
@@ -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(
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, {
+16 -2
View File
@@ -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<void> {
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";