"""把通用项目 A 的全部数据合并到项目 B,然后删除 A。 合并规则: - 子任务同名 → 归并到 B 的已有子任务,A 的执行记录改挂到该子任务 - 子任务仅在 A 存在 → 直接把该子任务转到 B 名下(保留名称) - A 的分类 / 负责人 / 描述 / 标签:仅当 B 缺失时继承 - 合并后按最新执行记录回算 B 的进度与状态(进度未满 100% 时不会显示为已完成) 实现注意(踩过的坑): - 项目/子任务的 relationship 都配了级联,直接 `db.delete(task)` 会把它名下的执行记录 外键置空。因此执行记录的改挂一律走 bulk UPDATE,不经过 ORM 关系。 - session 是 autoflush=False,改完必须显式 flush,否则后续统计读到的是旧数据。 - 删除源项目前要 expire_all(),避免 identity map 里的旧关系对象被级联误删。 用法: python scripts/merge_project.py --from "智空无人机平台" --to "智飞无人机平台" --dry-run python scripts/merge_project.py --from "智空无人机平台" --to "智飞无人机平台" """ from __future__ import annotations import argparse import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app.db import SessionLocal # noqa: E402 from app.models import Project, Task, Update # noqa: E402 def main(): ap = argparse.ArgumentParser() ap.add_argument("--from", dest="src", required=True, help="被合并的项目名") ap.add_argument("--to", dest="dst", required=True, help="目标项目名") ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() db = SessionLocal() try: src = db.query(Project).filter(Project.name == args.src).one_or_none() dst = db.query(Project).filter(Project.name == args.dst).one_or_none() if not src: print(f"!! 找不到项目「{args.src}」") return if not dst: print(f"!! 找不到项目「{args.dst}」") return if src.id == dst.id: print("!! 源与目标是同一个项目") return src_id, dst_id = src.id, dst.id n_src_tasks = db.query(Task).filter(Task.project_id == src_id).count() n_src_ups = db.query(Update).filter(Update.project_id == src_id).count() n_dst_tasks = db.query(Task).filter(Task.project_id == dst_id).count() n_dst_ups = db.query(Update).filter(Update.project_id == dst_id).count() print(f"合并: {src.name} (id={src_id}) → {dst.name} (id={dst_id})") print(f" 源:{n_src_tasks} 个子任务 / {n_src_ups} 条执行记录") print(f" 目标:{n_dst_tasks} 个子任务 / {n_dst_ups} 条执行记录") print("-" * 62) dst_map = { t.name: t for t in db.query(Task).filter(Task.project_id == dst_id).all() } merged, moved = [], [] for t in db.query(Task).filter(Task.project_id == src_id).order_by(Task.sort_order, Task.id).all(): n = db.query(Update).filter(Update.task_id == t.id).count() target = dst_map.get(t.name) if target: db.query(Update).filter(Update.task_id == t.id).update( {"task_id": target.id, "project_id": dst_id}, synchronize_session=False ) merged.append((t.name, n)) db.flush() # 先落库,再删子任务,避免外键被置空 db.delete(t) else: db.query(Update).filter(Update.task_id == t.id).update( {"project_id": dst_id}, synchronize_session=False ) t.project_id = dst_id dst_map[t.name] = t moved.append((t.name, n)) db.flush() # 源项目下没挂子任务的记录 orphans = db.query(Update).filter(Update.project_id == src_id).all() orphan_updates = len(orphans) for u in orphans: u.project_id = dst_id # 补齐目标项目缺失的元信息 inherited = [] if not dst.category_id and src.category_id: dst.category_id = src.category_id inherited.append("分类") if not dst.owner and src.owner: dst.owner = src.owner inherited.append("负责人") if not dst.description and src.description: dst.description = src.description inherited.append("描述") if src.is_key and not dst.is_key: dst.is_key = True inherited.append("重点标记") existing_tags = {t.name for t in dst.tags} for tag in src.tags: if tag.name not in existing_tags: dst.tags.append(tag) inherited.append(f"标签:{tag.name}") db.flush() # 回算目标项目的进度与状态 ups = ( db.query(Update) .filter(Update.project_id == dst_id) .order_by(Update.period_id.desc(), Update.id.desc()) .all() ) prs = [u.progress for u in ups if u.progress is not None] if prs: dst.progress = max(prs) if ups: dst.status = ups[0].status if dst.status == "done" and (dst.progress or 0) < 100: dst.status = "in_progress" dst_name = dst.name final_progress = dst.progress final_status = dst.status if args.dry_run: db.rollback() print(f"[dry-run] 归并同名子任务 {len(merged)} 个、迁移子任务 {len(moved)} 个、" f"转挂直属记录 {orphan_updates} 条") print("[dry-run] 未写入数据库") return # 清掉缓存中的旧关系对象,避免删源项目时级联误删已转挂的数据 db.expire_all() src = db.query(Project).filter(Project.id == src_id).one() remain_task = db.query(Task).filter(Task.project_id == src_id).count() remain_upd = db.query(Update).filter(Update.project_id == src_id).count() if remain_task or remain_upd: db.rollback() print(f"!! 源项目仍有 {remain_task} 个子任务 / {remain_upd} 条记录,已回滚") return db.delete(src) db.commit() print(f"归并同名子任务 {len(merged)} 个:") for name, n in merged: print(f" · {name[:38]:40s} 并入 {n} 条记录") print(f"迁移独有子任务 {len(moved)} 个") if orphan_updates: print(f"转挂源项目直属记录 {orphan_updates} 条") if inherited: print("继承到目标项目:", "、".join(inherited)) print("-" * 62) print(f"[ok] 已删除「{args.src}」") print(f"[ok] 「{dst_name}」现有 {db.query(Task).filter(Task.project_id == dst_id).count()} 个子任务 / " f"{db.query(Update).filter(Update.project_id == dst_id).count()} 条执行记录," f"进度 {final_progress:.0f}% / 状态 {final_status}") finally: db.close() if __name__ == "__main__": main()