172 lines
7.0 KiB
Python
172 lines
7.0 KiB
Python
"""把通用项目 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()
|