feat(B03): 계획노선 shapefile 입력과 PRJ 2개 분리를 지원한다

원청 정식 계획노선이 shapefile(UTM-K)로, 지형이 별도 PRJ(동부원점 Bessel)로
들어오는데 입력 경로가 shapefile 확장자를 막고 PRJ를 프로젝트당 1개로 전제했다.

- 업로드 허용에 .shp/.shx/.dbf/.cpg 추가, 한 번에 보낼 파일 수 5 -> 10
- B03_FileInput_Engine_Shapefile: ESRI 규격 직접 파싱(GDAL 미사용). 형제 파일이
  아직 안 왔어도 .shp 하나로 기하를 읽는다. .cpg 내용이 949뿐인 실물을 CP949로
  정규화해 한글 속성을 살린다.
- 노선 판독을 read_planned_route로 일원화(CSV/shapefile), PlannedRoute에
  crs_input 추가 - 변환 입력은 EPSG 코드가 아니라 crs_input_from_prj가 주는
  값(EPSG:n 또는 원문 WKT)이다. 실물 PRJ 2종 모두 to_epsg가 None이다.
- shapefile 세트를 input/shp/ 한 폴더에 모은다(GDAL 요건). 노선 PRJ가 그 안에
  남으므로 지형 PRJ(input/prj/)와 파일명 정렬 운에 기대지 않고 갈린다.
  find_project_prj가 지형 PRJ를 프로젝트 좌표계로 고른다.
- 필수 세트를 노선 1종(csv 또는 shp) + prj + tfw로 완화, shp면 shx/dbf 동반 필수.
- UI: 확장자 단독 슬롯 매칭을 basename 그룹핑으로 바꿔 노선 PRJ와 지형 PRJ가
  같은 슬롯을 다투지 않게 하고, 노선 슬롯이 파일 한 벌을 담아 함께 전송한다.

자체검증: tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과,
ruff check/format 통과. 전체 스위트 잔여 실패 11건은 HEAD 사본(git archive)에서
동일하게 재현되는 기존 실패다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 19:28:34 +09:00
co-authored by Claude Opus 5
parent 9bc722dfd6
commit fe72ea041b
18 changed files with 582 additions and 48 deletions
+40 -2
View File
@@ -11,6 +11,34 @@ from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
from common_util.common_util_storage import get_project_stage_path
from config.config_system import CHUNK_TEMP_DIR, UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
# shapefile은 형제 파일이 **같은 폴더·같은 basename**이라야 열린다. 확장자별 폴더에
# 흩어 두면 노선 자체를 못 읽는다. 그래서 세트는 `input/shp/`에 통째로 모은다.
# 부수 효과가 하나 더 있다 — 노선 PRJ가 이 폴더 안에 남으므로 지형 PRJ(`input/prj/`)와
# 파일명 정렬 운에 기대지 않고 갈린다 (2026-08-31).
_SHAPEFILE_FOLDER = "shp"
_SHAPEFILE_OWNED_EXT = frozenset({"shp", "shx", "dbf", "cpg"})
def reconcile_shapefile_members(stage_root: Path) -> list[Path]:
"""먼저 도착해 다른 폴더에 앉은 shapefile 형제 파일을 `input/shp/`로 모은다.
파일이 오는 순서를 강제할 수 없다 — PRJ가 SHP보다 먼저 오면 확장자 규칙만으로는
`input/prj/`에 앉는다. SHP가 들어온 시점에 같은 basename의 형제를 끌어온다.
"""
input_root = stage_root / "input"
shapefile_dir = input_root / _SHAPEFILE_FOLDER
if not shapefile_dir.exists():
return []
moved: list[Path] = []
for shape_path in shapefile_dir.glob("*.shp"):
for extension in ("shx", "dbf", "cpg", "prj"):
stray = input_root / extension / f"{shape_path.stem}.{extension}"
target = shapefile_dir / stray.name
if stray.exists() and not target.exists():
shutil.move(str(stray), str(target))
moved.append(target)
return moved
def resolve_upload_destination(
project_root: str | Path,
@@ -18,8 +46,18 @@ def resolve_upload_destination(
) -> Path:
"""검증된 파일의 B03 입력 저장 경로를 생성해 반환한다."""
stage_root = Path(get_project_stage_path(str(project_root), "B03_FileInput")).resolve()
file_type = Path(descriptor.original_filename).suffix.lower().lstrip(".")
destination = (stage_root / "input" / file_type / descriptor.original_filename).resolve()
reconcile_shapefile_members(stage_root)
source_name = Path(descriptor.original_filename)
file_type = source_name.suffix.lower().lstrip(".")
folder = file_type
if file_type in _SHAPEFILE_OWNED_EXT:
folder = _SHAPEFILE_FOLDER
elif file_type == "prj":
# 노선 shapefile의 짝 PRJ만 세트 폴더로. 지형 PRJ는 `input/prj/`에 남는다.
if (stage_root / "input" / _SHAPEFILE_FOLDER / f"{source_name.stem}.shp").exists():
folder = _SHAPEFILE_FOLDER
destination = (stage_root / "input" / folder / descriptor.original_filename).resolve()
if os.path.commonpath((stage_root, destination)) != str(stage_root):
raise ValueError("업로드 저장 경로가 B03 단계 폴더를 벗어났습니다.")
@@ -396,6 +396,10 @@ def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
extension = source.suffix.lower()
if extension == ".csv":
return analyze_planned_route_csv(source)
if extension == ".shp":
from B03_FileInput.B03_FileInput_Engine_Shapefile import analyze_shapefile_metadata
return analyze_shapefile_metadata(source)
if extension in {".las", ".laz"}:
return analyze_las_metadata(source)
if extension == ".prj":
@@ -0,0 +1,244 @@
"""계획노선 shapefile(.shp) 판독 — 기하·속성·좌표계.
GDAL/geopandas를 쓰지 않고 ESRI Shapefile 규격을 직접 읽는다. 업로드 **직후**
메타데이터를 내야 하는데 그 시점엔 형제 파일(.shx/.dbf/.prj)이 아직 다 도착하지
않았을 수 있고, GDAL은 한 짝이라도 비면 열기 자체를 실패하기 때문이다. 규격 파싱은
.shp 하나만으로 기하를 읽어 낸다.
좌표계는 EPSG 코드로 가리지 않는다 — 짝 PRJ 원문을 `crs_input_from_prj()`에 넘겨
"EPSG:n" 또는 **원문 WKT**를 그대로 변환기 입력으로 쓴다(2026-08-31 사용자 확정).
"""
import logging
import struct
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_HEADER_BYTES = 100
_FILE_CODE = 9994
# 규격상 폴리라인 계열만 계획노선으로 받는다. Z/M 변형도 XY는 같은 자리에 있다.
_POLYLINE_TYPES = {3: "PolyLine", 13: "PolyLineZ", 23: "PolyLineM"}
_SHAPE_TYPE_NAMES = {
0: "Null",
1: "Point",
3: "PolyLine",
5: "Polygon",
8: "MultiPoint",
11: "PointZ",
13: "PolyLineZ",
15: "PolygonZ",
18: "MultiPointZ",
21: "PointM",
23: "PolyLineM",
25: "PolygonM",
28: "MultiPointM",
31: "MultiPatch",
}
def _normalize_codepage(text: str) -> str | None:
""".cpg 내용을 파이썬 인코딩 이름으로 바꾼다.
실물은 `949` 한 줄만 들어 있다(2026-08-31 원청 자료) — `CP949`도 `EUC-KR`도
아니라서 그대로 넘기면 LookupError가 난다.
"""
value = (text or "").strip()
if not value:
return None
if value.isdigit():
return f"cp{value}"
upper = value.upper().replace("-", "").replace("_", "")
if upper in {"ANSI", "OEM", "SYSTEM"}:
return "cp949"
return value
def shapefile_encoding(path: Path) -> str:
"""짝 .cpg에 적힌 인코딩. 없으면 국내 자료 관례대로 CP949."""
cpg_path = path.with_suffix(".cpg")
if cpg_path.exists():
try:
encoding = _normalize_codepage(cpg_path.read_text(encoding="ascii", errors="ignore"))
if encoding:
"".encode(encoding) # 이름이 실재하는지 확인 — 없으면 LookupError
return encoding
except (OSError, LookupError):
logger.warning("shapefile .cpg 인코딩을 해석하지 못했습니다: %s", cpg_path.name)
return "cp949"
def shapefile_crs_input(path: Path) -> str | None:
"""짝 .prj를 `Transformer.from_crs` 입력 문자열로 정규화해 돌려준다."""
prj_path = path.with_suffix(".prj")
if not prj_path.exists():
return None
from common_util.common_util_crs import crs_input_from_prj
return crs_input_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
def shapefile_epsg_label(path: Path) -> int | None:
"""짝 .prj의 EPSG **라벨**. 변환에는 쓰지 않는다 — 메타 표시·로그용."""
prj_path = path.with_suffix(".prj")
if not prj_path.exists():
return None
from pyproj import CRS
from common_util.common_util_crs import identify_epsg
text = prj_path.read_text(encoding="utf-8", errors="ignore")
try:
return identify_epsg(CRS.from_wkt(text), text)
except Exception: # pragma: no cover — WKT 불량은 라벨 없음으로 흘린다
return None
def _read_header(blob: bytes) -> dict[str, Any]:
if len(blob) < _HEADER_BYTES:
raise ValueError("shapefile 헤더가 100바이트에 못 미칩니다.")
file_code = struct.unpack(">i", blob[0:4])[0]
if file_code != _FILE_CODE:
raise ValueError("shapefile 파일 코드가 규격(9994)과 다릅니다.")
declared_bytes = struct.unpack(">i", blob[24:28])[0] * 2
shape_type = struct.unpack("<i", blob[32:36])[0]
x_min, y_min, x_max, y_max = struct.unpack("<4d", blob[36:68])
z_min, z_max = struct.unpack("<2d", blob[68:84])
return {
"shape_type": shape_type,
"shape_type_name": _SHAPE_TYPE_NAMES.get(shape_type, f"Unknown({shape_type})"),
"declared_bytes": declared_bytes,
"bounds": {
"x_min": x_min,
"x_max": x_max,
"y_min": y_min,
"y_max": y_max,
"z_min": z_min,
"z_max": z_max,
},
}
def _read_polyline_record(blob: bytes, offset: int, content_bytes: int) -> list[list[tuple]]:
"""폴리라인 레코드 하나를 파트별 정점 목록으로 푼다."""
record_type = struct.unpack("<i", blob[offset : offset + 4])[0]
if record_type == 0: # Null shape — 규격상 건너뛴다
return []
if record_type not in _POLYLINE_TYPES:
raise ValueError(f"계획노선은 폴리라인이어야 합니다(받은 형상: {record_type}).")
cursor = offset + 4 + 32 # 형상종류 + 레코드 bbox
part_count, point_count = struct.unpack("<2i", blob[cursor : cursor + 8])
cursor += 8
parts = list(struct.unpack(f"<{part_count}i", blob[cursor : cursor + part_count * 4]))
cursor += part_count * 4
flat_xy = struct.unpack(f"<{point_count * 2}d", blob[cursor : cursor + point_count * 16])
cursor += point_count * 16
zs: tuple[float, ...] = ()
if record_type == 13:
cursor += 16 # Z 범위
end = cursor + point_count * 8
if end - offset <= content_bytes:
zs = struct.unpack(f"<{point_count}d", blob[cursor:end])
points = [
(flat_xy[index * 2], flat_xy[index * 2 + 1], zs[index] if zs else 0.0)
for index in range(point_count)
]
boundaries = [*parts, point_count]
return [points[boundaries[i] : boundaries[i + 1]] for i in range(part_count)]
def read_shapefile_parts(path: str | Path) -> list[list[tuple]]:
""".shp의 모든 폴리라인 파트를 (x, y, z) 정점 목록으로 읽는다."""
source = Path(path)
blob = source.read_bytes()
header = _read_header(blob)
if header["shape_type"] not in _POLYLINE_TYPES and header["shape_type"] != 0:
raise ValueError(
f"계획노선 shapefile은 폴리라인이어야 합니다(받은 형상: {header['shape_type_name']})."
)
limit = min(len(blob), header["declared_bytes"] or len(blob))
parts: list[list[tuple]] = []
offset = _HEADER_BYTES
while offset + 8 <= limit:
content_bytes = struct.unpack(">i", blob[offset + 4 : offset + 8])[0] * 2
if content_bytes <= 0:
break
parts.extend(_read_polyline_record(blob, offset + 8, content_bytes))
offset += 8 + content_bytes
return [part for part in parts if len(part) >= 2]
def read_shapefile_attributes(path: str | Path) -> dict[str, str]:
"""짝 .dbf 첫 레코드의 속성. 없거나 못 읽으면 빈 dict."""
dbf_path = Path(path).with_suffix(".dbf")
if not dbf_path.exists():
return {}
encoding = shapefile_encoding(Path(path))
try:
blob = dbf_path.read_bytes()
header_bytes, record_bytes = struct.unpack("<2H", blob[8:12])
fields: list[tuple[str, int]] = []
cursor = 32
while cursor < header_bytes - 1 and blob[cursor] != 0x0D:
descriptor = blob[cursor : cursor + 32]
name = descriptor[0:11].split(b"\x00")[0].decode(encoding, errors="replace").strip()
fields.append((name, descriptor[16]))
cursor += 32
record = blob[header_bytes : header_bytes + record_bytes]
if not record:
return {}
values: dict[str, str] = {}
position = 1 # 첫 바이트는 삭제 표시
for name, width in fields:
raw = record[position : position + width]
values[name] = raw.decode(encoding, errors="replace").strip()
position += width
return values
except (OSError, struct.error, ValueError):
logger.warning("shapefile .dbf 속성을 읽지 못했습니다: %s", dbf_path.name)
return {}
def _route_name_from_attributes(attributes: dict[str, str], fallback: str) -> str:
for key in ("대상지", "노선명", "route_name", "NAME", "name"):
value = attributes.get(key)
if value:
return value
return fallback
def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]:
"""계획노선 shapefile의 B03 메타데이터를 만든다."""
source = Path(path)
header = _read_header(source.read_bytes()[:_HEADER_BYTES])
parts = read_shapefile_parts(source)
attributes = read_shapefile_attributes(source)
point_count = sum(len(part) for part in parts)
missing = [
extension for extension in (".shx", ".dbf") if not source.with_suffix(extension).exists()
]
return {
"file": source.name,
"extension": "shp",
"size_bytes": source.stat().st_size,
"purpose": "planned_route",
"route_name": _route_name_from_attributes(attributes, source.stem),
"shape_type": header["shape_type_name"],
"part_count": len(parts),
"point_count": point_count,
"epsg": shapefile_epsg_label(source),
"crs_input": shapefile_crs_input(source),
"encoding": shapefile_encoding(source),
"attributes": attributes,
"missing_members": missing,
"bounds": header["bounds"],
"start_point": list(parts[0][0]) if parts else None,
"end_point": list(parts[-1][-1]) if parts else None,
}
+11 -4
View File
@@ -65,7 +65,11 @@ async def get_project_input_readiness(
connection: aiomysql.Connection,
project_id: UUID,
) -> tuple[set[str], int | None, int | None]:
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 CSV 입력 ID를 반환한다."""
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 입력 ID를 반환한다.
계획노선은 CSV 또는 shapefile이다. 둘 다 있으면 shapefile을 고른다 —
`find_planned_route_file()`의 우선순위와 같아야 WF1 입력과 실제 판독 대상이 갈리지 않는다.
"""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
@@ -83,12 +87,15 @@ async def get_project_input_readiness(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
None,
)
# LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 CSV가 분석 원천이 된다.
route_csv_id = next(
# LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 파일이 분석 원천이 된다.
route_id = next(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") == "shp"),
None,
) or next(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") == "csv"),
None,
)
return file_types, point_cloud_id, route_csv_id
return file_types, point_cloud_id, route_id
async def get_project_storage_relative_path(
+12 -5
View File
@@ -12,15 +12,22 @@ import aiomysql
from config.config_system import TEMP_UPLOAD_RETENTION_DAYS
# 묶음이 "완료"로 넘어가려면 있어야 하는 파일 종류. B03 필수 슬롯과 같은 기준이다.
REQUIRED_TEMP_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
REQUIRED_TEMP_FILE_TYPES = frozenset({"prj", "tfw"})
POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
# 계획노선은 CSV 또는 shapefile 중 하나 (2026-08-31).
ROUTE_FILE_TYPES = frozenset({"csv", "shp"})
SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf"})
def is_batch_required_complete(file_types: set[str]) -> bool:
"""필수 파일(csv·prj·tfw + las/laz 1종)이 모두 찼는지."""
return REQUIRED_TEMP_FILE_TYPES.issubset(file_types) and bool(
file_types & POINT_CLOUD_FILE_TYPES
)
"""필수 파일(계획노선 1종 + prj·tfw + las/laz 1종)이 모두 찼는지."""
if not REQUIRED_TEMP_FILE_TYPES.issubset(file_types):
return False
if not file_types & ROUTE_FILE_TYPES:
return False
if "shp" in file_types and not SHAPEFILE_REQUIRED_TYPES.issubset(file_types):
return False
return bool(file_types & POINT_CLOUD_FILE_TYPES)
async def create_temp_batch(
+11 -2
View File
@@ -76,8 +76,12 @@ _ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"})
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile).
_ROUTE_FILE_TYPES = frozenset({"csv", "shp"})
# shapefile은 이 셋이 다 있어야 열린다. .cpg는 없으면 CP949로 읽으므로 필수가 아니다.
_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf"})
def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int:
@@ -95,6 +99,11 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]:
missing = 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")
@@ -164,7 +173,7 @@ async def _complete_file_input_if_ready(
if not las_free:
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
if route_csv_input_id is None:
raise ValueError("계획 노선 CSV 입력 파일을 찾을 수 없습니다.")
raise ValueError("계획 노선 입력 파일(CSV 또는 shapefile)을 찾을 수 없습니다.")
# 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
stored_path = await get_project_storage_relative_path(connection, project_id)
+3 -3
View File
@@ -34,16 +34,16 @@ def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, f
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
read_planned_route,
)
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route_csv(route_file) if route_file else None
planned = read_planned_route(route_file) if route_file else None
if planned is None or len(planned.vertices) < 2:
return None
target_epsg = project_epsg_from_prj(project_root)
points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices]
source_epsg = f"EPSG:{planned.epsg}" if planned.epsg else target_epsg
source_epsg = planned.crs_input or target_epsg
if source_epsg.upper() != target_epsg.upper():
from pyproj import Transformer
+40 -6
View File
@@ -44,6 +44,7 @@ import {
getExtension,
initializeSlots,
makeSessionKey,
splitShapefileSelection,
type FileSlot,
type FileSlotState,
type StoredUploadSession,
@@ -289,6 +290,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
async function assignFileToSlot(
file: File,
targetSlot?: FileSlot,
companions: File[] = [],
): Promise<void> {
const extension = getExtension(file.name);
const state = targetSlot
@@ -323,6 +325,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
state.file = file;
state.companions = companions.length ? companions : undefined;
state.uploadSessionId = undefined;
state.uploadStatus = "pending";
state.progressBytes = 0;
@@ -353,8 +356,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다.
// 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른
// 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08).
// 계획노선 shapefile은 파일 한 벌이 슬롯 하나로 간다 — 확장자만 보면 노선 PRJ가
// 지형 PRJ 슬롯을 덮어쓴다(2026-08-31).
const { shapefile, rest } = splitShapefileSelection(files);
const occupied = new Set(selectedStates().map((state) => state.slot));
for (const file of files) {
if (shapefile) occupied.add(targetSlot ?? "csv");
for (const file of rest) {
const extension = getExtension(file.name);
const slot =
targetSlot ??
@@ -369,7 +376,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : "";
void (async () => {
for (const file of files) await assignFileToSlot(file, targetSlot);
if (shapefile) {
await assignFileToSlot(
shapefile.primary,
targetSlot,
shapefile.companions,
);
}
for (const file of rest) await assignFileToSlot(file, targetSlot);
await detectPausedUploads();
})();
}
@@ -381,6 +395,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
localStorage.removeItem(makeSessionKey(activeProjectId, state.file));
}
state.file = undefined;
state.companions = undefined;
state.uploadSessionId = undefined;
state.uploadStatus = "pending";
state.progressBytes = 0;
@@ -665,13 +680,32 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
setUploading(true);
const uploaded: UploadedFileResult[] = [];
try {
for (let index = 0; index < targetStates.length; index += 1) {
const state = targetStates[index];
// 슬롯 하나가 파일 여럿일 수 있다(계획노선 shapefile 세트) — 완료 신호는 **마지막
// 파일 한 건**에만 붙어야 하므로 슬롯이 아니라 파일 단위로 펼쳐서 센다.
const jobs: { state: FileSlotState; file: File }[] = [];
for (const state of targetStates) {
for (const companion of state.companions ?? [])
jobs.push({ state, file: companion });
if (state.file) jobs.push({ state, file: state.file });
}
for (let index = 0; index < jobs.length; index += 1) {
const { state, file } = jobs[index];
// 동반 파일은 진행률·세션을 대표 파일과 섞지 않도록 임시 상태로 올린다.
const uploadState =
file === state.file
? state
: {
...state,
file,
companions: undefined,
uploadSessionId: undefined,
progressBytes: 0,
};
uploaded.push(
...(await uploadOneFile(
activeProjectId,
state,
index === targetStates.length - 1,
uploadState,
index === jobs.length - 1,
() => renderSlot(state.slot),
lasFreeDesign,
)),
+45 -1
View File
@@ -13,6 +13,12 @@ export interface SlotConfig {
export interface FileSlotState extends SlotConfig {
file?: File;
/**
* shapefile의 (.shx/.dbf/.cpg/.prj).
* GDAL이 (2026-08-31).
* (`file`) .shp이고, .
*/
companions?: File[];
uploadSessionId?: string;
uploadStatus: UploadStatus;
progressBytes: number;
@@ -45,7 +51,7 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [
slot: "csv",
labelKey: "B03_File_Slot_PlannedRoute",
icon: "⌁",
extensions: [".csv"],
extensions: [".csv", ".shp"],
isRequired: true,
},
{
@@ -83,6 +89,44 @@ export function getExtension(fileName: string): string {
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
}
export function getBaseName(fileName: string): string {
const index = fileName.lastIndexOf(".");
return index >= 0 ? fileName.slice(0, index) : fileName;
}
/** shapefile 동반 파일. `.prj`가 여기 들어 있어 지형 PRJ와 basename으로 갈린다. */
const SHAPEFILE_COMPANION_EXT = [".shx", ".dbf", ".cpg", ".prj"];
/**
* shapefile .
*
* PRJ와 PRJ가 . `.shp`
* **basename이 ** , `.prj` .
*/
export function splitShapefileSelection(files: readonly File[]): {
shapefile: { primary: File; companions: File[] } | null;
rest: File[];
} {
const primary = files.find((file) => getExtension(file.name) === ".shp");
if (!primary) return { shapefile: null, rest: [...files] };
const stem = getBaseName(primary.name);
const companions: File[] = [];
const rest: File[] = [];
for (const file of files) {
if (file === primary) continue;
const extension = getExtension(file.name);
if (
SHAPEFILE_COMPANION_EXT.includes(extension) &&
getBaseName(file.name) === stem
) {
companions.push(file);
} else {
rest.push(file);
}
}
return { shapefile: { primary, companions }, rest };
}
export function formatBytes(bytes: number): string {
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(2)} GB`;