- 계획노선 shapefile 분석에 `preview_path`(200점 안팎 솎은 좌표열) 추가 — 이미 메모리에 있는 정점을 쓰므로 파일 재열람 0회(솎기 0.136ms) - LAS·GeoTIFF 는 사용자 확정대로 **bbox 사각형만** (점구름·래스터 렌더 안 함) - `upload-overview` 응답에 분석 `metadata` 동봉 — 재접속해도 같은 그림 - 카드에 SVG 미리보기 렌더(신규 `B03_FileInput_UI_Preview.ts`), 값 없으면 미표시 - 계획노선 카드는 지형 자료 범위와 대조 — 벗어나면 경고색·안내, 좌표계가 다르면 대조 생략(재투영 안 함). shapefile 은 좌표계가 없어 같은 세트 `.prj` 값을 씀 검증: 공용 브라우저 실측(노선 선 121점·범위 안/밖·좌표계 상이 3갈래), tmp/tests/test_b03_preview_path.py 2건, tsc --noEmit 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
414 lines
14 KiB
Python
414 lines
14 KiB
Python
"""B03 input_files 테이블의 aiomysql Raw SQL 접근."""
|
|
|
|
import json
|
|
from pathlib import PurePosixPath
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
|
|
|
|
async def create_input_file(
|
|
connection: aiomysql.Connection,
|
|
*,
|
|
project_id: UUID,
|
|
file_type: str,
|
|
original_filename: str,
|
|
relative_path: str,
|
|
file_size_bytes: int,
|
|
upload_by: int | None,
|
|
crs_epsg: int | None,
|
|
metadata: dict[str, Any],
|
|
) -> int:
|
|
"""업로드 원본 파일 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
|
normalized_path = PurePosixPath(relative_path)
|
|
if normalized_path.is_absolute() or ".." in normalized_path.parts:
|
|
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
|
|
if normalized_path.parts[:2] != ("B03_FileInput", "input"):
|
|
raise ValueError("입력 파일 경로는 B03_FileInput/input 아래여야 합니다.")
|
|
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
INSERT INTO input_files (
|
|
project_id,
|
|
file_type,
|
|
original_filename,
|
|
raw_file_path,
|
|
file_size_mb,
|
|
upload_by,
|
|
crs_epsg,
|
|
metadata,
|
|
status
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'UPLOADED')
|
|
""",
|
|
(
|
|
str(project_id),
|
|
file_type,
|
|
original_filename,
|
|
normalized_path.as_posix(),
|
|
file_size_bytes / (1024 * 1024),
|
|
upload_by,
|
|
crs_epsg,
|
|
json.dumps(metadata, ensure_ascii=False),
|
|
),
|
|
)
|
|
input_file_id = cursor.lastrowid
|
|
|
|
if not input_file_id:
|
|
raise RuntimeError("input_files 레코드 생성 결과에 ID가 없습니다.")
|
|
return int(input_file_id)
|
|
|
|
|
|
async def get_project_input_readiness(
|
|
connection: aiomysql.Connection,
|
|
project_id: UUID,
|
|
) -> tuple[set[str], int | None, int | None]:
|
|
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 입력 ID를 반환한다.
|
|
|
|
계획노선은 CSV 또는 shapefile이다. 둘 다 있으면 shapefile을 고른다 —
|
|
`find_planned_route_file()`의 우선순위와 같아야 WF1 입력과 실제 판독 대상이 갈리지 않는다.
|
|
|
|
PRJ는 노선용·지형용 두 장이 온다. DB `file_type`은 둘 다 `prj`라 그대로 세면 노선
|
|
PRJ 하나로 필수가 채워진다 — 프로젝트 좌표계를 정하는 것은 **지형 PRJ**이므로,
|
|
노선 세트 폴더(`input/shp/`)에 있는 PRJ는 `route_prj`로 갈라 센다(2026-08-31).
|
|
"""
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT id, LOWER(file_type) AS file_type, raw_file_path
|
|
FROM input_files
|
|
WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED')
|
|
ORDER BY id DESC
|
|
""",
|
|
(str(project_id),),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
|
|
file_types: set[str] = set()
|
|
for row in rows:
|
|
file_type = str(row.get("file_type") or "")
|
|
if not file_type:
|
|
continue
|
|
if file_type == "prj" and "/input/shp/" in str(row.get("raw_file_path") or ""):
|
|
file_type = "route_prj"
|
|
file_types.add(file_type)
|
|
point_cloud_id = next(
|
|
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
|
|
None,
|
|
)
|
|
# 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_id
|
|
|
|
|
|
async def get_project_storage_relative_path(
|
|
connection: aiomysql.Connection,
|
|
project_id: UUID,
|
|
) -> str:
|
|
"""프로젝트의 검증된 저장소 상대 경로를 조회한다."""
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT storage_path
|
|
FROM projects
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
""",
|
|
(str(project_id),),
|
|
)
|
|
row = await cursor.fetchone()
|
|
|
|
if not row or not row[0]:
|
|
raise LookupError("프로젝트 또는 프로젝트 저장 경로를 찾을 수 없습니다.")
|
|
|
|
normalized_path = PurePosixPath(str(row[0]).replace("\\", "/"))
|
|
if normalized_path.is_absolute() or ".." in normalized_path.parts:
|
|
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
|
|
return normalized_path.as_posix()
|
|
|
|
|
|
async def create_upload_session(
|
|
connection: aiomysql.Connection,
|
|
*,
|
|
session_id: str,
|
|
project_id: UUID,
|
|
original_filename: str,
|
|
file_size_bytes: int,
|
|
chunk_size_bytes: int,
|
|
total_chunks: int,
|
|
) -> None:
|
|
"""청크 업로드 세션을 생성하거나 동일 세션 ID를 갱신한다."""
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
INSERT INTO upload_sessions (
|
|
id,
|
|
project_id,
|
|
original_filename,
|
|
file_size_bytes,
|
|
chunk_size_bytes,
|
|
total_chunks,
|
|
completed_chunks,
|
|
status,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, 0, 'in_progress', NOW(), NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
original_filename = VALUES(original_filename),
|
|
file_size_bytes = VALUES(file_size_bytes),
|
|
chunk_size_bytes = VALUES(chunk_size_bytes),
|
|
total_chunks = VALUES(total_chunks),
|
|
status = 'in_progress',
|
|
updated_at = NOW()
|
|
""",
|
|
(
|
|
session_id,
|
|
str(project_id),
|
|
original_filename,
|
|
file_size_bytes,
|
|
chunk_size_bytes,
|
|
total_chunks,
|
|
),
|
|
)
|
|
|
|
|
|
async def get_upload_session(
|
|
connection: aiomysql.Connection,
|
|
*,
|
|
project_id: UUID,
|
|
session_id: str,
|
|
) -> dict[str, Any]:
|
|
"""청크 업로드 세션을 조회한다."""
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
project_id,
|
|
original_filename,
|
|
file_size_bytes,
|
|
chunk_size_bytes,
|
|
total_chunks,
|
|
completed_chunks,
|
|
status
|
|
FROM upload_sessions
|
|
WHERE id = %s AND project_id = %s
|
|
""",
|
|
(session_id, str(project_id)),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
raise LookupError("업로드 세션을 찾을 수 없습니다.")
|
|
return dict(row)
|
|
|
|
|
|
async def upsert_upload_chunk(
|
|
connection: aiomysql.Connection,
|
|
*,
|
|
session_id: str,
|
|
chunk_index: int,
|
|
chunk_hash: str,
|
|
size_bytes: int,
|
|
stored_at: str,
|
|
) -> int:
|
|
"""청크 저장 정보를 기록하고 완료 청크 수를 반환한다."""
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
INSERT INTO upload_chunks (
|
|
session_id,
|
|
chunk_index,
|
|
chunk_hash,
|
|
size_bytes,
|
|
stored_at,
|
|
completed_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
chunk_hash = VALUES(chunk_hash),
|
|
size_bytes = VALUES(size_bytes),
|
|
stored_at = VALUES(stored_at),
|
|
completed_at = NOW()
|
|
""",
|
|
(session_id, chunk_index, chunk_hash, size_bytes, stored_at),
|
|
)
|
|
await cursor.execute(
|
|
"""
|
|
SELECT COUNT(*)
|
|
FROM upload_chunks
|
|
WHERE session_id = %s
|
|
""",
|
|
(session_id,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
completed_chunks = int(row[0]) if row else 0
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE upload_sessions
|
|
SET completed_chunks = %s, updated_at = NOW()
|
|
WHERE id = %s
|
|
""",
|
|
(completed_chunks, session_id),
|
|
)
|
|
return completed_chunks
|
|
|
|
|
|
async def list_completed_chunk_indexes(
|
|
connection: aiomysql.Connection,
|
|
*,
|
|
session_id: str,
|
|
) -> list[int]:
|
|
"""완료된 청크 인덱스 목록을 반환한다."""
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT chunk_index
|
|
FROM upload_chunks
|
|
WHERE session_id = %s
|
|
ORDER BY chunk_index ASC
|
|
""",
|
|
(session_id,),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [int(row[0]) for row in rows]
|
|
|
|
|
|
async def mark_upload_session_completed(
|
|
connection: aiomysql.Connection,
|
|
*,
|
|
session_id: str,
|
|
) -> None:
|
|
"""업로드 세션을 완료 상태로 표시한다."""
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE upload_sessions
|
|
SET status = 'completed', updated_at = NOW()
|
|
WHERE id = %s
|
|
""",
|
|
(session_id,),
|
|
)
|
|
|
|
|
|
async def mark_upload_session_failed(
|
|
connection: aiomysql.Connection,
|
|
*,
|
|
session_id: str,
|
|
) -> None:
|
|
"""업로드 세션을 실패 상태로 표시한다."""
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE upload_sessions
|
|
SET status = 'failed', updated_at = NOW()
|
|
WHERE id = %s
|
|
""",
|
|
(session_id,),
|
|
)
|
|
|
|
|
|
async def list_project_input_files(
|
|
connection: aiomysql.Connection,
|
|
project_id: UUID,
|
|
) -> list[dict[str, Any]]:
|
|
"""업로드 완료된 입력 파일 목록(재접속 현황 표시용) — 서버가 정본이다.
|
|
|
|
같은 파일명을 다시 올리면 새 레코드가 쌓이므로 파일명별 최신 것만 남긴다.
|
|
"""
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT f.id, f.file_type, f.original_filename, f.file_size_mb, f.status,
|
|
f.upload_at, f.raw_file_path, f.metadata
|
|
FROM input_files f
|
|
INNER JOIN (
|
|
SELECT MAX(id) AS id
|
|
FROM input_files
|
|
WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED')
|
|
GROUP BY original_filename
|
|
) latest ON latest.id = f.id
|
|
ORDER BY f.id ASC
|
|
""",
|
|
(str(project_id),),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
async def list_incomplete_upload_sessions(
|
|
connection: aiomysql.Connection,
|
|
project_id: UUID,
|
|
) -> list[dict[str, Any]]:
|
|
"""중단된(미완료) 청크 업로드 세션 목록 — 재접속 시 이어올리기 안내용."""
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT id, original_filename, file_size_bytes, chunk_size_bytes,
|
|
total_chunks, completed_chunks, updated_at
|
|
FROM upload_sessions
|
|
WHERE project_id = %s AND status = 'in_progress'
|
|
ORDER BY updated_at DESC
|
|
""",
|
|
(str(project_id),),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
async def find_input_file_by_name(
|
|
connection: aiomysql.Connection,
|
|
project_id: UUID,
|
|
original_filename: str,
|
|
) -> dict[str, Any] | None:
|
|
"""같은 이름으로 등록된 최신 입력 파일 1건. 없으면 None.
|
|
|
|
같은 파일을 다시 올렸는지 가리는 데 쓴다 — 중복 판정 기준은 파일명이고, 내용이 같은지는
|
|
이 행의 메타데이터에 적힌 지문으로 본다(2026-08-08 사용자 결정).
|
|
"""
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
SELECT id, original_filename, file_size_mb, metadata
|
|
FROM input_files
|
|
WHERE project_id = %s AND original_filename = %s
|
|
AND status IN ('UPLOADED', 'PROCESSED')
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
""",
|
|
(str(project_id), original_filename),
|
|
)
|
|
row = await cursor.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
async def supersede_previous_input_files(
|
|
connection: aiomysql.Connection,
|
|
project_id: UUID,
|
|
original_filename: str,
|
|
keep_input_file_id: int,
|
|
) -> int:
|
|
"""같은 이름의 옛 행을 `SUPERSEDED`로 내린다. 내린 건수를 돌려준다.
|
|
|
|
조회 쿼리들이 `UPLOADED`/`PROCESSED`만 보므로, 이렇게만 해도 목록·분석에서 빠진다.
|
|
행을 지우지 않는 이유는 언제 무엇이 교체됐는지 추적할 근거를 남기기 위해서다.
|
|
"""
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE input_files
|
|
SET status = 'SUPERSEDED'
|
|
WHERE project_id = %s AND original_filename = %s AND id <> %s
|
|
AND status IN ('UPLOADED', 'PROCESSED')
|
|
""",
|
|
(str(project_id), original_filename, keep_input_file_id),
|
|
)
|
|
return cursor.rowcount
|