"""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, supersede_previous_input_files, ) # 분리 전 이 파일에 있던 이름은 그대로 다시 내보낸다 — 옛 이름을 참조하는 # 테스트·스크립트가 깨지지 않게 하기 위함이다(2026-09-04). from B03_FileInput.B03_FileInput_Router_Chunks import ( create_project_upload_session as create_project_upload_session, ) from B03_FileInput.B03_FileInput_Router_Chunks import ( finalize_project_upload as finalize_project_upload, ) from B03_FileInput.B03_FileInput_Router_Chunks import ( get_project_upload_status as get_project_upload_status, ) from B03_FileInput.B03_FileInput_Router_Chunks import router as chunk_router from B03_FileInput.B03_FileInput_Router_Chunks import ( upload_project_chunk as upload_project_chunk, ) from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response from B03_FileInput.B03_FileInput_Router_Helpers import ( _ANALYSIS_RUNNING_MESSAGE as _ANALYSIS_RUNNING_MESSAGE, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _BACKGROUND_TASKS as _BACKGROUND_TASKS, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _POINT_CLOUD_FILE_TYPES as _POINT_CLOUD_FILE_TYPES, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _REQUIRED_FILE_TYPES as _REQUIRED_FILE_TYPES, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _ROUTE_FILE_TYPES as _ROUTE_FILE_TYPES, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _SHAPEFILE_REQUIRED_TYPES as _SHAPEFILE_REQUIRED_TYPES, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( OutputsWouldBeDiscarded, _confirm_replace_response, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _already_uploaded as _already_uploaded, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _complete_file_input_if_ready as _complete_file_input_if_ready, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _get_project_notification_info as _get_project_notification_info, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _is_point_cloud_result as _is_point_cloud_result, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _missing_required_file_types as _missing_required_file_types, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _require_complete_file_set as _require_complete_file_set, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _schedule_background_task as _schedule_background_task, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _send_upload_complete_notification as _send_upload_complete_notification, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _stored_fingerprint as _stored_fingerprint, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _total_chunks as _total_chunks, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _update_project_status as _update_project_status, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( _write_stage_metadata as _write_stage_metadata, ) from B03_FileInput.B03_FileInput_Router_Helpers import ( upload_file_types as upload_file_types, ) from B03_FileInput.B03_FileInput_Schema import ( FileUploadDescriptor, FileUploadResponse, UploadedFileResult, UploadLockInfo, 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 ( analysis_lock_owner, get_workflow_state, is_analysis_running, ) 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), confirm_replace: 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 파일을 올릴 수 없습니다.", }, ) # 지형 파일은 도엽별로 여러 장이 올 수 있다 — 합쳐서 전처리한다(2026-09-06 사용자 확정). if not las_free and las_count < 1: return JSONResponse( status_code=400, content={ "status": "error", "message": "LAS 또는 LAZ 파일을 1개 이상 포함해야 합니다.", }, ) # 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다 # (2026-09-04 사용자 지시: 사용자는 CSV 를 쓰지 않음. CSV 는 내부 정본 한 벌뿐). route_count = sum(Path(filename).suffix.lower() in {".csv", ".shp"} for filename in filenames) if route_count != 1: return JSONResponse( status_code=400, content={ "status": "error", "message": "계획노선 파일(shapefile 의 .shp 또는 .csv)을 정확히 1개 포함해야 합니다.", }, ) request_file_types = upload_file_types(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)) # 분석이 도는 중이면 새 자료를 받지 않는다 — 청크·임시배치 경로와 같은 가드다 # (2026-09-08). 이 갈래만 빠져 있어 분석 2개가 같은 산출물 경로에서 부딪혔다. async with connection.cursor(aiomysql.DictCursor) as cursor: if await is_analysis_running(cursor, str(project_id)): return JSONResponse( status_code=409, content={"status": "error", "message": _ANALYSIS_RUNNING_MESSAGE}, ) 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, ) # 같은 이름의 옛 행은 내려 둔다 — 청크 경로와 같은 처리다(2026-09-08). # 이 갈래만 빠져 있어 같은 파일이 두 줄로 활성으로 남았다. await supersede_previous_input_files( connection, project_id, descriptor.original_filename, input_file_id, ) 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, confirm_replace ) 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"]), started_by=session.get("user_id"), ), task_name=f"b04-preprocess-auto-{project_id}", ) return FileUploadResponse(project_id=str(project_id), files=results) except OutputsWouldBeDiscarded as exc: for saved_path in saved_paths: saved_path.unlink(missing_ok=True) return _confirm_replace_response(exc) 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)) # 「누가 올리는 중인지」 — 서버가 막는 것과 화면이 알리는 것이 한 값에서 나온다. lock = await analysis_lock_owner(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 ], # stage 0 을 마쳤으면 서버 필수검사를 이미 통과한 것이다 — LAS 없이 설계는 # 지형 한 벌(prj·tfw)이 아예 없으므로 여기서 다시 세면 B04 이동이 막힌다. required_complete=stage0_complete or (_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None), analysis_complete=analysis_complete, analysis_lock=UploadLockInfo(**lock) if lock else None, ) 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}