"""프로젝트 통째 복제 — **검증용 프로젝트를 창마다 하나씩 뜨는 도구** (2026-09-08). ⚠ **왜 저장소에 두나** — 네 창이 **같은 원본에서 같은 방법으로** 떠야 수치를 견줄 수 있다. `tmp/` 는 창마다 따로라 서로 못 봄(2026-09-08 랩탑 두 창이 막힌 자리). ⚠ **원본은 읽기만 한다** — SELECT 뿐이고 원본 행·파일에 쓰지 않는다. ⚠ 다만 **복제 중에 원본이 바뀌면 창마다 복제본이 달라진다** — 원본을 얼린 뒤 뜰 것. 무엇을 뜨나 DB projects · project_members · input_files · processed_point_cloud · surface_models · terrain_layers · routes · route_points · route_statistics · longitudinal_sections · cross_sections · project_workflow_stages ⚠ 자동증가 id 는 **새로 받고** 참조(외래키)를 새 id 로 다시 이어 붙인다. 파일 저장소 폴더 통째(robocopy). 경로가 프로젝트 루트 기준 상대라 그대로 쓰인다. 치환 파일 안에 박힌 **옛 프로젝트 UUID**, 초기값 스냅숏의 **surface_model_id**. ⚠ **DB 만 뜨거나 파일만 떠서는 안 된다** — 수량(B08)은 DB(측점·횡단)를 보고, 구조물·관 정본은 파일(`structures.json`·`pipe_points.json`)이라 **둘 다** 있어야 화면이 선다. 쓰는 법 ./venv/Scripts/python.exe db_management/tools_clone_project.py <원본 UUID> "<새 이름>" [--owner <사용자 id>] [--members ] (없으면 원본 주인 · 참여자 그대로) storage 만(DB 는 이미 복제됨 — 다른 PC 의 storage 를 맞출 때) ./venv/Scripts/python.exe db_management/tools_clone_project.py \\ <원본 UUID> <새 UUID> --storage-only """ import argparse import asyncio import json import subprocess import sys import time from pathlib import Path from uuid import uuid4 sys.path.append(str(Path(__file__).resolve().parent.parent)) import aiomysql from common_util.common_util_storage import resolve_stored_project_path from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER def _args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="프로젝트를 통째로 복제한다(원본은 읽기만).") parser.add_argument("source_id", help="원본 프로젝트 UUID") parser.add_argument("new_name", help="새 프로젝트 이름(--storage-only 면 새 프로젝트 UUID)") parser.add_argument( "--storage-only", action="store_true", help="DB 는 읽기만 · storage 폴더 복사와 파일 안 id 바꿈만", ) parser.add_argument("--owner", type=int, help="새 주인 사용자 id(없으면 원본 주인)") parser.add_argument("--members", help="참여자 사용자 id 쉼표 목록(없으면 원본 참여자)") return parser.parse_args() ARGS = _args() SOURCE_ID = ARGS.source_id NEW_NAME = ARGS.new_name async def _rows(cursor, sql, args): await cursor.execute(sql, args) return await cursor.fetchall() async def _insert(cursor, table, row: dict) -> int: names = ", ".join(f"`{key}`" for key in row) holders = ", ".join(["%s"] * len(row)) await cursor.execute(f"INSERT INTO `{table}` ({names}) VALUES ({holders})", tuple(row.values())) return int(cursor.lastrowid) def _copy_folder(source_root: Path, new_root: Path) -> None: print(f"파일 복사 시작: {source_root.name} -> {new_root.name}") started = time.perf_counter() result = subprocess.run( [ "robocopy", str(source_root), str(new_root), "/E", "/NFL", "/NDL", "/NJH", "/NJS", "/MT:8", ], capture_output=True, text=True, ) if result.returncode >= 8: raise RuntimeError(f"robocopy 실패 (코드 {result.returncode})") print(f"파일 복사 완료: {time.perf_counter() - started:.0f}초") def _fix_files(new_root: Path, new_id: str, model_map: dict) -> None: replaced = [] for path in new_root.rglob("*.json"): try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue if SOURCE_ID not in text: continue path.write_text(text.replace(SOURCE_ID, new_id), encoding="utf-8") replaced.append(str(path.relative_to(new_root))) print("프로젝트 id 치환:", replaced) snapshot = new_root / "initial_snapshot" / "db.json" if snapshot.is_file(): dump = json.loads(snapshot.read_text(encoding="utf-8")) changed = False for route in dump.get("routes", []): old_model = route.get("surface_model_id") if old_model in model_map: route["surface_model_id"] = model_map[old_model] changed = True if changed: snapshot.write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") print("초기값 스냅샷 지표면 id 교정:", changed) async def storage_only(connection) -> None: """DB 는 SELECT 만 — 원본 · 새 프로젝트 행(이미 복제됨)으로 폴더 자리 · 지표면 id 짝.""" new_id = NEW_NAME paths = {} models = {} async with connection.cursor(aiomysql.DictCursor) as cursor: for project_id in (SOURCE_ID, new_id): row = await _rows( cursor, "SELECT storage_path FROM projects WHERE id = %s", (project_id,) ) paths[project_id] = row[0]["storage_path"] rows = await _rows( cursor, "SELECT id FROM surface_models WHERE project_id = %s ORDER BY id", (project_id,), ) models[project_id] = [item["id"] for item in rows] # 복제는 지표면 행을 원본 id 차례대로 넣는다 — 차례로 짝 지음. model_map = dict(zip(models[SOURCE_ID], models[new_id], strict=True)) new_root = Path(resolve_stored_project_path(paths[new_id])) if any(path.is_file() for path in new_root.rglob("*")): raise RuntimeError(f"새 폴더에 파일이 이미 있음: {new_root}") _copy_folder(Path(resolve_stored_project_path(paths[SOURCE_ID])), new_root) _fix_files(new_root, new_id, model_map) print(f"storage 만 복제 완료: {new_root}") async def main() -> None: connection = await aiomysql.connect( host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, charset="utf8mb4", ) try: if ARGS.storage_only: await storage_only(connection) return async with connection.cursor(aiomysql.DictCursor) as cursor: source = (await _rows(cursor, "SELECT * FROM projects WHERE id = %s", (SOURCE_ID,)))[0] input_files = await _rows( cursor, "SELECT * FROM input_files WHERE project_id = %s ORDER BY id", (SOURCE_ID,) ) clouds = await _rows( cursor, "SELECT * FROM processed_point_cloud WHERE project_id = %s ORDER BY id", (SOURCE_ID,), ) models = await _rows( cursor, "SELECT * FROM surface_models WHERE project_id = %s ORDER BY id", (SOURCE_ID,), ) members = await _rows( cursor, "SELECT * FROM project_members WHERE project_id = %s", (SOURCE_ID,) ) layers = await _rows( cursor, "SELECT t.* FROM terrain_layers t JOIN surface_models s" " ON s.id = t.surface_model_id WHERE s.project_id = %s ORDER BY t.id", (SOURCE_ID,), ) routes = await _rows( cursor, "SELECT * FROM routes WHERE project_id = %s ORDER BY id", (SOURCE_ID,) ) stages = await _rows( cursor, "SELECT * FROM project_workflow_stages WHERE project_id = %s ORDER BY stage_no", (SOURCE_ID,), ) route_ids = [route["id"] for route in routes] points: list = [] statistics: list = [] longitudinal: list = [] crosses: list = [] if route_ids: holders = ", ".join(["%s"] * len(route_ids)) points = await _rows( cursor, f"SELECT * FROM route_points WHERE route_id IN ({holders}) ORDER BY id", route_ids, ) statistics = await _rows( cursor, f"SELECT * FROM route_statistics WHERE route_id IN ({holders}) ORDER BY id", route_ids, ) longitudinal = await _rows( cursor, "SELECT * FROM longitudinal_sections" f" WHERE route_id IN ({holders}) ORDER BY id", route_ids, ) crosses = await _rows( cursor, f"SELECT * FROM cross_sections WHERE route_id IN ({holders}) ORDER BY id", route_ids, ) new_id = str(uuid4()) source_root = Path(resolve_stored_project_path(source["storage_path"])) new_storage = "/".join(source["storage_path"].split("/")[:-1] + [new_id]) new_root = source_root.parent / new_id # 1) 파일 먼저 - 실패하면 DB 를 건드리지 않은 채로 끝난다. _copy_folder(source_root, new_root) # 2) DB - 한 트랜잭션으로. await connection.begin() async with connection.cursor(aiomysql.DictCursor) as cursor: project = dict(source) project["id"] = new_id project["name"] = NEW_NAME project["storage_path"] = new_storage if ARGS.owner is not None: project["user_id"] = ARGS.owner await _insert(cursor, "projects", project) member_ids = ( [int(user) for user in ARGS.members.split(",") if user.strip()] if ARGS.members is not None else [row["user_id"] for row in members] ) for user in member_ids: await _insert(cursor, "project_members", {"project_id": new_id, "user_id": user}) file_map: dict[int, int] = {} for row in input_files: row = dict(row) old = row.pop("id") row["project_id"] = new_id file_map[old] = await _insert(cursor, "input_files", row) cloud_map: dict[int, int] = {} for row in clouds: row = dict(row) old = row.pop("id") row["project_id"] = new_id row["input_file_id"] = file_map.get(row["input_file_id"], row["input_file_id"]) cloud_map[old] = await _insert(cursor, "processed_point_cloud", row) model_map: dict[int, int] = {} for row in models: row = dict(row) old = row.pop("id") row["project_id"] = new_id row["source_file_id"] = file_map.get(row["source_file_id"], row["source_file_id"]) row["processed_cloud_id"] = cloud_map.get( row["processed_cloud_id"], row["processed_cloud_id"] ) model_map[old] = await _insert(cursor, "surface_models", row) for row in layers: row = dict(row) row.pop("id") row["surface_model_id"] = model_map[row["surface_model_id"]] await _insert(cursor, "terrain_layers", row) route_map: dict[int, int] = {} for row in routes: row = dict(row) old = row.pop("id") row["project_id"] = new_id row["surface_model_id"] = model_map.get( row["surface_model_id"], row["surface_model_id"] ) route_map[old] = await _insert(cursor, "routes", row) for table, rows in ( ("route_points", points), ("route_statistics", statistics), ("longitudinal_sections", longitudinal), ("cross_sections", crosses), ): for row in rows: row = dict(row) row.pop("id") row["route_id"] = route_map.get(row["route_id"], row["route_id"]) if "project_id" in row: row["project_id"] = new_id await _insert(cursor, table, row) for row in stages: row = dict(row) row.pop("id") row["project_id"] = new_id params = row.get("params") if params: # 단계 설정에 박힌 옛 id 를 새 id 로 바꾼다(입력 파일·지표면 모델). data = json.loads(params) if data.get("input_file_id") is not None: old_file = int(data["input_file_id"]) data["input_file_id"] = str(file_map.get(old_file, old_file)) if data.get("surface_model_id") is not None: old_model = int(data["surface_model_id"]) data["surface_model_id"] = model_map.get(old_model, old_model) row["params"] = json.dumps(data, ensure_ascii=False) await _insert(cursor, "project_workflow_stages", row) await connection.commit() print( f"DB 복제 완료: 참여자 {len(member_ids)} · 입력파일 {len(file_map)}" f" · 지표면 {len(model_map)} · 지형층 {len(layers)}" f" · 노선 {len(route_map)}" f" · 정점 {len(points)} · 횡단 {len(crosses)} · 단계 {len(stages)}" ) # 3) 파일 안에 박힌 옛 프로젝트 id 치환 + 초기값 스냅샷의 지표면 모델 id 교정. _fix_files(new_root, new_id, model_map) print(f"\n새 프로젝트: {NEW_NAME}\n id = {new_id}\n 저장소 = {new_storage}") finally: connection.close() asyncio.run(main())