"""B03 파일 입력 FastAPI 라우터.""" import asyncio import json import logging from pathlib import Path from typing import Any from uuid import UUID import aiomysql from fastapi import APIRouter, Depends, File, Form, UploadFile from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Engine import ( resolve_upload_destination, save_upload_stream, ) from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata from B03_FileInput.B03_FileInput_Repository import ( create_input_file, get_project_input_readiness, get_project_storage_relative_path, list_incomplete_upload_sessions, list_project_input_files, ) from B03_FileInput.B03_FileInput_Router_Chunks import router as chunk_router from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response from B03_FileInput.B03_FileInput_Router_Helpers import ( _REQUIRED_FILE_TYPES, _complete_file_input_if_ready, _missing_required_file_types, _schedule_background_task, _write_stage_metadata, ) from B03_FileInput.B03_FileInput_Schema import ( FileUploadDescriptor, FileUploadResponse, UploadedFileResult, UploadOverviewFile, UploadOverviewResponse, UploadOverviewSession, ) 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_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow import load_project_workflow from common_util.common_util_workflow_state import ( get_workflow_state, ) from config.config_db import get_db_pool from config.config_system import ( UPLOAD_MAX_FILES, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B03 File Input"]) # 청크 업로드 엔드포인트는 파일이 700줄을 넘어 떼어냈다(2026-09-04) — 경로는 그대로다. router.include_router(chunk_router) @router.post("/{project_id}/files", response_model=FileUploadResponse) async def upload_project_files( project_id: UUID, files: list[UploadFile] = File(...), las_free: bool = Form(False), session: dict[str, Any] = Depends(verify_session), ) -> FileUploadResponse | JSONResponse: """프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다.""" if not files or len(files) > UPLOAD_MAX_FILES: return JSONResponse( status_code=400, content={ "status": "error", "message": f"파일은 1~{UPLOAD_MAX_FILES}개까지 가능합니다.", }, ) filenames = [upload.filename or "" for upload in files] if len({filename.casefold() for filename in filenames}) != len(filenames): return JSONResponse( status_code=400, content={"status": "error", "message": "동일한 파일명을 중복 업로드할 수 없습니다."}, ) las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames) # LAS 없는 설계(las_free)는 LAS를 **0개만** 받는다. 섞여 들어오면 되돌린다 — # 올려 두면 전처리가 어느 쪽 경로인지 갈리지 않는다(2026-08-30 사용자 지시). if las_free and las_count: return JSONResponse( status_code=400, content={ "status": "error", "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", }, ) if not las_free and las_count != 1: return JSONResponse( status_code=400, content={ "status": "error", "message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.", }, ) csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames) if csv_count != 1: return JSONResponse( status_code=400, content={ "status": "error", "message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.", }, ) request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames} missing_required = _missing_required_file_types(request_file_types, las_free) if missing_required: return JSONResponse( status_code=400, content={ "status": "error", "message": f"B03 필수 입력 파일이 없습니다: {', '.join(missing_required)}", }, ) pool = get_db_pool() saved_paths: list[Path] = [] try: results: list[UploadedFileResult] = [] point_cloud_input_id: int | None = None async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) await connection.begin() try: for upload in files: preliminary = FileUploadDescriptor( original_filename=upload.filename or "", size_bytes=max(upload.size or 0, 1), ) destination = resolve_upload_destination(project_root, preliminary) written_bytes = await save_upload_stream(upload, destination) saved_paths.append(destination) descriptor = FileUploadDescriptor( original_filename=preliminary.original_filename, size_bytes=written_bytes, ) metadata = await asyncio.to_thread(analyze_input_metadata, destination) relative_path = destination.relative_to(project_root).as_posix() file_type = destination.suffix.lower().lstrip(".") crs_epsg = metadata.get("epsg") input_file_id = await create_input_file( connection, project_id=project_id, file_type=file_type, original_filename=descriptor.original_filename, relative_path=relative_path, file_size_bytes=written_bytes, upload_by=None, crs_epsg=int(crs_epsg) if crs_epsg is not None else None, metadata=metadata, ) results.append( UploadedFileResult( input_file_id=input_file_id, original_filename=descriptor.original_filename, file_type=file_type, relative_path=relative_path, size_bytes=written_bytes, metadata=metadata, ) ) point_cloud_input_id = await _complete_file_input_if_ready( connection, project_id, las_free ) await connection.commit() except Exception: await connection.rollback() raise stage_root = project_root / "B03_FileInput" _write_stage_metadata(stage_root, project_id, results) workflow_path = project_root / "workflow.json" if not workflow_path.exists(): atomic_write_json(workflow_path, load_project_workflow(project_root)) # 업로드 직후 안내 메일은 보내지 않는다 — 초기 설계(B04~B06)까지 마친 뒤 # WF1 서비스가 통합 메일 한 통을 보낸다(2026-08-08 사용자 지시). 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 FileUploadResponse(project_id=str(project_id), files=results) except LookupError as exc: return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) except (OSError, ValueError) as exc: for saved_path in saved_paths: saved_path.unlink(missing_ok=True) return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) except Exception: for saved_path in saved_paths: saved_path.unlink(missing_ok=True) logger.exception("B03 파일 업로드 처리 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "파일 업로드 처리 중 오류가 발생했습니다."}, ) finally: for upload in files: await upload.close() def _parse_metadata(raw: Any) -> dict[str, Any] | None: """DB에 JSON 문자열로 저장된 분석 메타데이터를 dict로 돌린다(깨지면 생략).""" if isinstance(raw, dict): return raw if not raw: return None try: parsed = json.loads(raw) except (TypeError, ValueError): return None return parsed if isinstance(parsed, dict) else None @router.get("/{project_id}/upload-overview", response_model=UploadOverviewResponse) async def get_project_upload_overview( project_id: UUID, session: dict[str, Any] = Depends(verify_session), ) -> UploadOverviewResponse | JSONResponse: """B03 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. localStorage 기반 표시는 캐시를 지우거나 다른 PC로 가면 사라진다(2026-08-04 사용자 보고). 화면은 진입 시 이 응답을 정본으로 삼고 localStorage는 보조로만 쓴다. """ pool = get_db_pool() try: async with pool.acquire() as connection: files = await list_project_input_files(connection, project_id) sessions = await list_incomplete_upload_sessions(connection, project_id) file_types, point_cloud_id, _route_csv_id = await get_project_input_readiness( connection, project_id ) async with connection.cursor(aiomysql.DictCursor) as cursor: state = await get_workflow_state(cursor, str(project_id)) stages = (state or {}).get("stages") or [] analysis_complete = any( int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE" for stage in stages ) # LAS 없는 설계로 stage 0을 마친 프로젝트는 LAS가 없어도 필수 충족으로 본다. stage0_complete = any( int(stage.get("stage_no", -1)) == 0 and str(stage.get("state")) == "COMPLETE" for stage in stages ) return UploadOverviewResponse( files=[ UploadOverviewFile( input_file_id=int(row["id"]), file_type=str(row["file_type"]), original_filename=str(row["original_filename"]), file_size_mb=float(row["file_size_mb"] or 0.0), status=str(row["status"]), uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None, relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None), metadata=_parse_metadata(row.get("metadata")), ) for row in files ], pending_sessions=[ UploadOverviewSession( upload_session_id=str(row["id"]), original_filename=str(row["original_filename"]), file_size_bytes=int(row["file_size_bytes"]), total_chunks=int(row["total_chunks"]), completed_chunks=int(row["completed_chunks"]), progress_percent=round( 100.0 * int(row["completed_chunks"]) / max(1, int(row["total_chunks"])), 1 ), updated_at=str(row["updated_at"]) if row.get("updated_at") else None, ) for row in sessions ], required_complete=_REQUIRED_FILE_TYPES <= file_types and (point_cloud_id is not None or stage0_complete), analysis_complete=analysis_complete, ) except Exception: logger.exception("B03 업로드 현황 조회 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "업로드 현황 조회 중 오류가 발생했습니다."}, ) @router.get("/{project_id}/workflow-state") async def get_project_workflow_state(project_id: str): pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: state = await get_workflow_state(cursor, project_id) return {"status": "success", "workflow_state": state}