fix(B03): rerun analysis on reupload

Clear derived browser caches so B05 loads the newly generated route and section data.
This commit is contained in:
2026-08-29 20:17:37 +09:00
parent 3640591c46
commit 60bbe89edb
8 changed files with 63 additions and 140 deletions
+2
View File
@@ -86,6 +86,7 @@ export async function createUploadSession(
file: File,
chunkSizeBytes: number,
fingerprint?: string | null,
completeUpload = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST",
@@ -96,6 +97,7 @@ export async function createUploadSession(
size_bytes: file.size,
chunk_size_bytes: chunkSizeBytes,
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
}),
});
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
+40 -13
View File
@@ -54,6 +54,7 @@ from B03_FileInput.B03_FileInput_Schema import (
)
from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email
from common_util.common_util_auth import verify_session
from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot
from common_util.common_util_json import atomic_write_json
from common_util.common_util_project_reset import purge_project_outputs
from common_util.common_util_storage import resolve_stored_project_path
@@ -160,9 +161,10 @@ async def _complete_file_input_if_ready(
# 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
stored_path = await get_project_storage_relative_path(connection, project_id)
await purge_project_outputs(
connection, str(project_id), Path(resolve_stored_project_path(stored_path))
)
project_root = Path(resolve_stored_project_path(stored_path))
clear_designing(project_root)
discard_initial_snapshot(project_root)
await purge_project_outputs(connection, str(project_id), project_root)
async with connection.cursor(aiomysql.DictCursor) as cursor:
await reset_stages_after_input_change(cursor, str(project_id))
await complete_stage(cursor, str(project_id), 0)
@@ -429,11 +431,14 @@ async def upload_project_files(
async def create_project_upload_session(
project_id: UUID,
payload: ChunkSessionCreateRequest,
session: dict[str, Any] = Depends(verify_session),
) -> ChunkSessionCreateResponse | JSONResponse:
"""대용량 파일 청크 업로드 세션을 생성한다."""
chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES)
total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes)
session_id = str(uuid4())
point_cloud_input_id: int | None = None
skipped: ChunkSessionCreateResponse | None = None
pool = get_db_pool()
try:
@@ -449,16 +454,38 @@ async def create_project_upload_session(
)
skipped = await _already_uploaded(connection, project_id, payload)
if skipped is not None:
return skipped
await create_upload_session(
connection,
session_id=session_id,
project_id=project_id,
original_filename=payload.original_filename,
file_size_bytes=payload.size_bytes,
chunk_size_bytes=chunk_size_bytes,
total_chunks=total_chunks,
)
if payload.complete_upload:
await connection.begin()
try:
point_cloud_input_id = await _complete_file_input_if_ready(
connection,
project_id,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
else:
await create_upload_session(
connection,
session_id=session_id,
project_id=project_id,
original_filename=payload.original_filename,
file_size_bytes=payload.size_bytes,
chunk_size_bytes=chunk_size_bytes,
total_chunks=total_chunks,
)
if skipped is not None:
if point_cloud_input_id is not None:
_schedule_background_task(
trigger_wf1_analysis_and_email(
project_id=project_id,
input_file_id=point_cloud_input_id,
user_role=str(session["role"]),
),
task_name=f"b04-preprocess-auto-{project_id}",
)
return skipped
return ChunkSessionCreateResponse(
project_id=str(project_id),
upload_session_id=session_id,
+1
View File
@@ -56,6 +56,7 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
"""청크 업로드 세션 생성 요청."""
chunk_size_bytes: int = Field(default=UPLOAD_CHUNK_SIZE_BYTES, gt=0)
complete_upload: bool = False
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
fingerprint: str | None = Field(default=None, max_length=128)
+11 -6
View File
@@ -11,8 +11,11 @@ import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
import { navigateTo } from "../A00_Common/router";
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { createTempPicker } from "./B03_FileInput_UI_TempPicker";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
@@ -64,6 +67,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
let pageRoot: HTMLElement | null = null;
let isUploading = false;
function clearDerivedCaches(projectId: string): void {
clearRouteLatestCache(projectId);
invalidateSectionDetail(projectId);
clearPreloadMark();
}
const subtitle = document.createElement("p");
subtitle.className = "b03-file__subtitle";
subtitle.textContent = L("B03_File_Subtitle");
@@ -488,6 +497,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
pageError.textContent = "";
try {
const result = await attachTempBatch(activeProjectId, batch.batch_id);
clearDerivedCaches(activeProjectId);
tempPicker.clear();
showToast(L("B03_Temp_Attach_Success"), "success");
await applyUploadOverview();
@@ -563,15 +573,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
)),
);
}
clearDerivedCaches(activeProjectId);
renderUploadResults(resultList, uploaded);
showToast(L("B03_File_Upload_Success"), "success");
// 모든 파일이 기존 파일과 같으면 새 분석이 시작되지 않는다.
if (uploaded.length === 0) {
navigateTo(completionRoute);
return;
}
showToast(L("B03_File_Analysis_InProgress"), "info");
const analysisComplete = await pollAnalysis(activeProjectId);
+7 -1
View File
@@ -110,7 +110,13 @@ 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);
const created = await createUploadSession(
projectId,
file,
chunkSizeBytes,
fingerprint,
completeUpload,
);
if (created.already_uploaded) {
state.progressBytes = file.size;
state.etaSeconds = 0;
@@ -1,58 +0,0 @@
import tempfile
import unittest
from pathlib import Path
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv
class PlannedRouteCsvTest(unittest.TestCase):
def analyze(self, content: str) -> dict:
with tempfile.TemporaryDirectory() as temporary_dir:
path = Path(temporary_dir) / "planned_route.csv"
path.write_text(content, encoding="utf-8")
return analyze_planned_route_csv(path)
def test_valid_route_returns_metadata(self) -> None:
metadata = self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,183493.5,489290.335,544.659,5187\n"
"sample,2,183500.0,489300.0,545.0,5187\n"
)
self.assertEqual(metadata["purpose"], "planned_route")
self.assertEqual(metadata["route_name"], "sample")
self.assertEqual(metadata["point_count"], 2)
self.assertEqual(metadata["epsg"], 5187)
self.assertEqual(metadata["start_point"], [183493.5, 489290.335, 544.659])
def test_missing_header_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "필수 열"):
self.analyze("route_name,sequence,x,y,crs_epsg\nsample,1,183493.5,489290.335,5187\n")
def test_non_numeric_coordinate_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "x 값은 숫자"):
self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,not-a-number,489290.335,544.659,5187\n"
"sample,2,183500.0,489300.0,545.0,5187\n"
)
def test_non_contiguous_sequence_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "sequence는 2"):
self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,183493.5,489290.335,544.659,5187\n"
"sample,3,183500.0,489300.0,545.0,5187\n"
)
def test_mixed_epsg_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "모든 행에서 같아야"):
self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,183493.5,489290.335,544.659,5187\n"
"sample,2,183500.0,489300.0,545.0,5186\n"
)
if __name__ == "__main__":
unittest.main()
@@ -1,60 +0,0 @@
import json
import tempfile
import unittest
from pathlib import Path
from uuid import UUID
from B03_FileInput.B03_FileInput_Router import (
_missing_required_file_types,
_write_stage_metadata,
)
from B03_FileInput.B03_FileInput_Schema import UploadedFileResult
class B03RouterHelperTest(unittest.TestCase):
def test_required_file_types_include_planned_route(self) -> None:
self.assertEqual(
_missing_required_file_types({"las", "prj", "tfw"}),
["csv"],
)
self.assertEqual(
_missing_required_file_types({"csv", "laz", "prj", "tfw"}),
[],
)
def test_stage_metadata_preserves_existing_files(self) -> None:
project_id = UUID("acb9170b-9ac8-49b3-82a0-51cfa32bb42d")
with tempfile.TemporaryDirectory() as temporary_dir:
stage_root = Path(temporary_dir)
(stage_root / "metadata.json").write_text(
json.dumps(
{
"project_id": str(project_id),
"files": [
{
"original_filename": "terrain.las",
"relative_path": "B03_FileInput/input/las/terrain.las",
}
],
}
),
encoding="utf-8",
)
route = UploadedFileResult(
input_file_id=100,
original_filename="planned_route.csv",
file_type="csv",
relative_path="B03_FileInput/input/csv/planned_route.csv",
size_bytes=1000,
metadata={"purpose": "planned_route", "epsg": 5187},
)
_write_stage_metadata(stage_root, project_id, [route])
payload = json.loads((stage_root / "metadata.json").read_text(encoding="utf-8"))
self.assertEqual(len(payload["files"]), 2)
self.assertEqual(payload["files"][1]["metadata"]["purpose"], "planned_route")
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -743,8 +743,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
try {
// ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다.
const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([
// 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다.
loadLatest(),
// 다른 탭의 재업로드로 옛 route_id가 남을 수 있어 진입 때는 DB 최신값을 읽는다.
loadLatest(true),
fetchSectionContext(activeProjectId),
fetchRoadWidths(activeProjectId),
]);