"""프로젝트 자리 바꿔 넣기 — 원본 내용을 대상 프로젝트 id 자리에 넣음 (요구 603 · PLAN 761-2). `common_util_project_copy` 의 full 복제와 같은 표 · 같은 id 다시 잇기 · 같은 파일 치환을 쓰되 새 프로젝트를 만들지 않고 **대상 행을 그대로 둠**(id · 이름 · 주인 · 참여자 · 종류 · 회사 · 담당자 · 저장 자리 · 만든 때). 대상의 옛 행(복제 표 목록)은 지우고 원본 행으로 채움 · storage 폴더는 원본과 똑같이 맞춤(robocopy /MIR — 원본에 없는 파일 지움). 넣기 전 대상 행을 `backup_dir/<대상 id>.json` 에 덤프(되돌림 재료) · storage 는 백업 안 함. 원본은 읽기만. """ from __future__ import annotations import json from pathlib import Path from typing import Any import aiomysql from common_util.common_util_project_copy import ( KIND_LABELS, _copy_folder, _fix_files, _insert_rows, _source_rows, _tree_files, _uncovered, _write_manifest, ) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_time import utc_now_text #: 대상 projects 행에서 그대로 두는 칸 — 나머지 칸은 원본 값 KEEP_PROJECT_COLUMNS = ( "id", "name", "user_id", "company_id", "project_kind", "storage_path", "created_at", "deleted_at", "pm_user_id", "field_lead_user_id", "designer_user_id", "logo_asset_id", "signature_asset_id", ) #: 대상에서 지우는 표(자식 → 부모) — projects · project_members 는 둠 ROUTE_CHILD_TABLES = ("cross_sections", "longitudinal_sections", "route_statistics", "route_points") PROJECT_TABLES = ( "routes", "terrain_layers", "surface_models", "processed_point_cloud", "input_files", "project_workflow_stages", "plan_route_years", "plan_route_assignees", ) def merged_project_row(source: dict[str, Any], target: dict[str, Any]) -> dict[str, Any]: """대상 projects 행에 쓸 값 — 유지 칸은 대상 · 나머지는 원본(id 뺀 SET 몫).""" return { key: (target[key] if key in KEEP_PROJECT_COLUMNS else value) for key, value in source.items() if key != "id" and key != "updated_at" } async def _delete_target_rows(cursor, target_id: str, rows: dict[str, list]) -> dict[str, int]: """대상 옛 행 지움(자식 → 부모) → 표 → 지운 행 수.""" removed: dict[str, int] = {} route_ids = [route["id"] for route in rows["routes"]] for table in ROUTE_CHILD_TABLES: removed[table] = 0 if route_ids: holders = ", ".join(["%s"] * len(route_ids)) removed[table] = await cursor.execute( f"DELETE FROM `{table}` WHERE route_id IN ({holders})", route_ids ) for table in PROJECT_TABLES: if table == "terrain_layers": model_ids = [model["id"] for model in rows["surface_models"]] removed[table] = 0 if model_ids: holders = ", ".join(["%s"] * len(model_ids)) removed[table] = await cursor.execute( f"DELETE FROM terrain_layers WHERE surface_model_id IN ({holders})", model_ids ) continue removed[table] = await cursor.execute( f"DELETE FROM `{table}` WHERE project_id = %s", (target_id,) ) return removed async def replace_project( connection, source_id: str, target_id: str, *, backup_dir: Path ) -> dict[str, Any]: """원본 내용을 대상 id 자리에 넣음 → 보고(표 행 수 · 지운 행 수 · 백업 자리).""" if source_id == target_id: raise ValueError("원본과 대상이 같음") async with connection.cursor(aiomysql.DictCursor) as cursor: rows = await _source_rows(cursor, source_id) skipped = await _uncovered(cursor, source_id, rows) old = await _source_rows(cursor, target_id) left = await _uncovered(cursor, target_id, old) source, target = rows["projects"][0], old["projects"][0] if source.get("project_kind") != target.get("project_kind"): raise ValueError( f"종류가 다름: 원본 {source.get('project_kind')} · 대상 {target.get('project_kind')}" ) source_root = Path(resolve_stored_project_path(source["storage_path"])) target_root = Path(resolve_stored_project_path(target["storage_path"])) # /MIR 은 대상 폴더를 원본과 똑같이 지움 — 자리가 대상 id 폴더일 때만 if target_root.name != target_id or target_root.resolve() == source_root.resolve(): raise ValueError(f"대상 저장 자리가 이상함: {target_root}") if skipped or left: print("⚠ 복제 목록 밖 표(안 뜸 · 대상 행 그대로):", {"원본": skipped, "대상": left}) backup_dir.mkdir(parents=True, exist_ok=True) backup = backup_dir / f"{target_id}.json" backup.write_text(json.dumps(old, ensure_ascii=False, default=str), encoding="utf-8") print(f"대상 행 백업: {backup}") # 1) 파일 먼저 — 실패하면 DB 를 건드리지 않은 채로 끝남 _copy_folder(source_root, target_root, mirror=True) # 2) DB — 지우기 · projects 칸 · 다시 넣기 한 트랜잭션 await connection.begin() try: async with connection.cursor(aiomysql.DictCursor) as cursor: removed = await _delete_target_rows(cursor, target_id, old) values = merged_project_row(source, target) sets = ", ".join(f"`{key}` = %s" for key in values) await cursor.execute( f"UPDATE projects SET {sets} WHERE id = %s", (*values.values(), target_id) ) model_map = await _insert_rows(cursor, rows, target_id) await connection.commit() except Exception: await connection.rollback() raise # 3) 파일 안 옛 프로젝트 id · 초기값 스냅샷 지표면 id _fix_files(target_root, source_id, target_id, model_map) tables = {name: len(items) for name, items in rows.items()} _write_manifest( target_root, { "scope": "replace", "kind": KIND_LABELS.get(source.get("project_kind"), source.get("project_kind")), "source_project_id": source_id, "project_id": target_id, "copied_at": utc_now_text(), "tables": tables, "removed": removed, "skipped_tables": skipped, "files": _tree_files(target_root), }, ) print(f"자리 바꿔 넣기 완료: {target['name']} ({target_id}) ← {source['name']}") return {"tables": tables, "removed": removed, "skipped": skipped, "backup": str(backup)}