diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 6747cb5b..2af94831 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -94,6 +94,9 @@ from B03_FileInput.B03_FileInput_Router_Helpers import ( 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, @@ -156,12 +159,13 @@ async def upload_project_files( "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", }, ) - if not las_free and las_count != 1: + # 지형 파일은 도엽별로 여러 장이 올 수 있다 — 합쳐서 전처리한다(2026-09-06 사용자 확정). + if not las_free and las_count < 1: return JSONResponse( status_code=400, content={ "status": "error", - "message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.", + "message": "LAS 또는 LAZ 파일을 1개 이상 포함해야 합니다.", }, ) # 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다 @@ -175,7 +179,7 @@ async def upload_project_files( "message": "계획노선 파일(shapefile 의 .shp 또는 .csv)을 정확히 1개 포함해야 합니다.", }, ) - request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames} + request_file_types = upload_file_types(filenames) missing_required = _missing_required_file_types(request_file_types, las_free) if missing_required: return JSONResponse( @@ -350,8 +354,10 @@ async def get_project_upload_overview( ) for row in sessions ], - required_complete=_REQUIRED_FILE_TYPES <= file_types - and (point_cloud_id is not None or stage0_complete), + # 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, ) except Exception: diff --git a/B03_FileInput/B03_FileInput_Router_Helpers.py b/B03_FileInput/B03_FileInput_Router_Helpers.py index 7b895adb..2251fc9c 100644 --- a/B03_FileInput/B03_FileInput_Router_Helpers.py +++ b/B03_FileInput/B03_FileInput_Router_Helpers.py @@ -62,14 +62,34 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool: return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES +def upload_file_types(filenames: list[str]) -> set[str]: + """업로드 요청의 파일명을 **완료 검사와 같은 기준**으로 유형화한다. + + 확장자만 세면 노선 세트의 `.prj`가 지형 PRJ로 오인돼, 요청 검사는 통과하고 저장 뒤 + 완료 검사에서 누락으로 갈린다(2026-09-06 실측 — LAS 없이 설계가 이 어긋남으로 막혔다). + 노선 도형(`.shp`)과 basename이 같은 PRJ는 화면의 카드 배정과 같은 규칙으로 `route_prj`. + """ + route_stem = next( + (Path(name).stem for name in filenames if Path(name).suffix.lower() == ".shp"), None + ) + types: set[str] = set() + for name in filenames: + suffix = Path(name).suffix.lower().lstrip(".") + if suffix == "prj" and route_stem is not None and Path(name).stem == route_stem: + suffix = "route_prj" + types.add(suffix) + return types + + def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]: - missing = sorted(_REQUIRED_FILE_TYPES - file_types) + # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 지형 한 벌(포인트클라우드·지형 PRJ· + # TFW)을 통째로 받지 않는다 — 화면도 그 카드들을 필수에서 뺀다. + missing = [] if las_free else sorted(_REQUIRED_FILE_TYPES - file_types) if not file_types.intersection(_ROUTE_FILE_TYPES): missing.append("csv/shp") # shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다. if "shp" in file_types: missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types)) - # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다. if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES): missing.append("las/laz") return missing @@ -143,6 +163,15 @@ async def _complete_file_input_if_ready( # 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) + # 지형 파일 여러 장은 합쳐서 전처리한다 — 다른 사업지 파일이 섞이면 합친 범위가 + # 통째로 어긋나므로 여기서 막는다(2026-09-06 사용자 지시). 머리글만 읽어 즉시 끝난다. + from B04_PreProcess.B04_PreProcess_Engine_Structurize import merge_gap_error + from B04_PreProcess.B04_PreProcess_Repository import list_project_point_cloud_paths + + terrain_paths = await list_project_point_cloud_paths(connection, project_id, project_root) + gap_message = merge_gap_error(terrain_paths) + if gap_message: + raise ValueError(gap_message) clear_designing(project_root) discard_initial_snapshot(project_root) await purge_project_outputs(connection, str(project_id), project_root)