Files
Aislo/B03_FileInput/B03_FileInput_Repository.py
T
eomsangdonandClaude Opus 5 1eb5ad4a60 feat(B03): 입력 화면을 계획노선/지형 두 컨테이너로 나누고 파일마다 카드를 준다
노선 파일이 다섯이면 카드도 다섯이어야 어느 것이 왔는지 보인다(사용자 지시).
세트를 슬롯 하나에 몰아 담던 companions 구조를 걷어내고, 슬롯 하나가 파일 하나를
갖는 기존 구조로 되돌렸다 - 업로드 루프도 원래대로다.

- 왼쪽 컨테이너 계획노선 자료: csv(.csv/.shp) shx dbf cpg route_prj
- 오른쪽 컨테이너 지형 자료(LAS): las_laz prj(지형 좌표계) tfw tif + LAS 없는 설계 토글
- .prj만 확장자로 안 갈린다 - 노선 도형과 basename이 같으면 노선 좌표계 카드,
  아니면 지형 카드(planSlotAssignments). 재접속 현황은 저장 경로(input/shp/)로 가른다.
- 재접속 현황 응답에 relative_path 추가.
- 필수 판정이 노선 PRJ를 route_prj로 따로 센다 - 지형 PRJ 없이 통과하던 구멍을 막았다.
- 형제 카드(shx/dbf/route_prj)는 노선 도형이 shapefile일 때만 필수 - 확장자 줄의
  "선택" 꼬리표가 실시간으로 붙고 떨어진다.
- 카드가 좁아져 한글 제목이 글자 단위로 접히던 것을 word-break: keep-all과 헤더
  flex-wrap으로 고쳤다.

화면 검증(공용 브라우저): 실물 7파일을 한 번에 떨어뜨려 배정 실측 - route.prj는
노선 좌표계 카드, terrain.prj는 지형 카드로 갈렸고 카드 제목 9개 모두 한 줄.
tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:50:39 +09:00

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
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