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
+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({