Files
Aislo/common_util/common_util_project_copy_replace.py
T
eomsangdonandClaude Opus 5.5 4db52b5504 feat(761-2): 복제 도구 자리 바꿔 넣기 --replace — 대상 id · 이름 · 주인 · 참여자 · 종류 유지
- common_util_project_copy_replace: 대상 옛 행(복제 표 목록) 지우고 원본 행으로 채움 · projects 는 유지 칸 밖만 원본 값 · storage 를 원본과 똑같이(robocopy /MIR) · 넣기 전 대상 행 덤프(--backup-dir)
- common_util_project_copy: 행 넣기를 _insert_rows 로 떼어 full 복제 · 바꿔 넣기가 함께 씀 · robocopy 출력 글자 깨짐 무시
- 지킴 시험 test_761_2_clone_replace(유지 칸 · 지우는 표 = 복제 표 · id 다시 잇기)
- test_532_1 낡은 단정 고침: 지표면 지워진 옛 노선의 NULL(ON DELETE SET NULL)을 새 id 밖 값으로 잘못 셈
- 실제 한 번: 가이드 → 임시 복제 → 그 자리에 --replace → 행 수 · 파일 같음 · 추천 4 꺼내짐 → 앱 영구 삭제 길로 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:57:40 +09:00

164 lines
6.6 KiB
Python

"""프로젝트 자리 바꿔 넣기 — 원본 내용을 대상 프로젝트 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)}