unis_manager/scripts/merge_project.py

172 lines
7.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""把通用项目 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()