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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user