From 60bbe89edbf965b2ee25e51f497f94b35b3c21d0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 20:17:37 +0900 Subject: [PATCH] fix(B03): rerun analysis on reupload Clear derived browser caches so B05 loads the newly generated route and section data. --- B03_FileInput/B03_FileInput_Api_Fetch.ts | 2 + B03_FileInput/B03_FileInput_Router.py | 53 ++++++++++++---- B03_FileInput/B03_FileInput_Schema.py | 1 + B03_FileInput/B03_FileInput_UI_Page.ts | 17 ++++-- B03_FileInput/B03_FileInput_UI_Upload.ts | 8 ++- .../test_B03_FileInput_Engine_Analyze.py | 58 ------------------ B03_FileInput/test_B03_FileInput_Router.py | 60 ------------------- B05_Profile/B05_Profile_UI_Page.ts | 4 +- 8 files changed, 63 insertions(+), 140 deletions(-) delete mode 100644 B03_FileInput/test_B03_FileInput_Engine_Analyze.py delete mode 100644 B03_FileInput/test_B03_FileInput_Router.py diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 0d48f9f1..2bb31c47 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -86,6 +86,7 @@ export async function createUploadSession( file: File, chunkSizeBytes: number, fingerprint?: string | null, + completeUpload = false, ): Promise { 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(response); diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 9d7bcad9..8fc2850b 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -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, diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 9b656033..0dff7e03 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -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) diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index be036522..73280f5d 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -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 { 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 { 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 { )), ); } + 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); diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index a2a292e6..90b57c79 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -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; diff --git a/B03_FileInput/test_B03_FileInput_Engine_Analyze.py b/B03_FileInput/test_B03_FileInput_Engine_Analyze.py deleted file mode 100644 index 39dfa9d3..00000000 --- a/B03_FileInput/test_B03_FileInput_Engine_Analyze.py +++ /dev/null @@ -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() diff --git a/B03_FileInput/test_B03_FileInput_Router.py b/B03_FileInput/test_B03_FileInput_Router.py deleted file mode 100644 index 84f7671b..00000000 --- a/B03_FileInput/test_B03_FileInput_Router.py +++ /dev/null @@ -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() diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 72da2379..9c2e30e2 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -743,8 +743,8 @@ export async function renderB05Route(root: HTMLElement): Promise { try { // ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다. const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([ - // 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다. - loadLatest(), + // 다른 탭의 재업로드로 옛 route_id가 남을 수 있어 진입 때는 DB 최신값을 읽는다. + loadLatest(true), fetchSectionContext(activeProjectId), fetchRoadWidths(activeProjectId), ]);