"""把「定制开发」从通用项目体系拆出,独立成定开项目。 数据来源: 1. 《项目明细表》台账(区域 / 金额 / 类别 / 状态说明) 2. 原「定制开发」项目下的子任务(子项目名,部分自带金额)与各周期执行记录 产出:custom_projects + custom_updates 两张独立表; 原 projects / tasks / updates 中的「定制开发」整体移除,常规看板只留平台产品类项目。 用法:python scripts/migrate_custom.py [--dry-run] """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import argparse # noqa: E402 from app.ai_parser import _guess_progress # noqa: E402 from app.constants import ( # noqa: E402 CUSTOM_STAGE_CODES, guess_custom_stage, infer_region, normalize_custom_name, ) from app.db import SessionLocal, engine # noqa: E402 from app.models import ( # noqa: E402 Base, CustomProject, CustomUpdate, LedgerItem, Period, Project, ) CONTAINER = "定制开发" def main(): ap = argparse.ArgumentParser() ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() Base.metadata.create_all(bind=engine) db = SessionLocal() # ---------------------------------------------------------------- 台账 by_name: dict[str, CustomProject] = {} created_from_ledger = 0 for it in db.query(LedgerItem).order_by(LedgerItem.id).all(): clean, amt = normalize_custom_name(it.name) if not clean: continue key = clean cp = by_name.get(key) if cp is None: cp = CustomProject( name=clean, region=it.region, amount=it.amount if it.amount else amt, stage=guess_custom_stage(it.status_note, it.category), progress=0, remark=it.status_note, period_id=it.period_id, is_key=(it.category == "重点项目"), source="import", ) db.add(cp) db.flush() by_name[key] = cp created_from_ledger += 1 else: if not cp.amount and (it.amount or amt): cp.amount = it.amount or amt if not cp.region and it.region: cp.region = it.region # ---------------------------------------------------------------- 原「定制开发」子任务 container = db.query(Project).filter(Project.name == CONTAINER).one_or_none() created_from_task = 0 moved_updates = 0 if container: for t in sorted(container.tasks, key=lambda x: (x.sort_order, x.id)): clean, amt = normalize_custom_name(t.name) if not clean: continue cp = by_name.get(clean) or by_name.get(t.name) if cp is None: cp = CustomProject( name=clean, amount=amt, stage="signed", progress=0, source="import", ) db.add(cp) db.flush() by_name[clean] = cp created_from_task += 1 elif not cp.amount and amt: cp.amount = amt # 该子项目的各周期执行记录 → custom_updates for u in t.updates: stage = guess_custom_stage(u.content) cu = CustomUpdate( custom_project_id=cp.id, period_id=u.period_id, stage=stage, content=u.content, summary=u.summary, progress=u.progress, risk=u.risk, next_step=u.next_step, owner=u.owner, source="import", ) db.add(cu) moved_updates += 1 # 用最新记录回写项目状态 if cp.period_id is None or u.period_id >= cp.period_id: cp.period_id = u.period_id cp.stage = stage cp.remark = (u.content or "")[:500] if u.progress is not None: cp.progress = u.progress # ------------------------------------------------------------ 清理常规表 if not args.dry_run: db.delete(container) db.query(LedgerItem).delete() # ---------------------------------------------------------------- 收尾 for cp in by_name.values(): if cp.stage not in CUSTOM_STAGE_CODES: cp.stage = "signed" if cp.stage in ("delivered", "accepted", "revenue", "closed") and not cp.progress: cp.progress = 100 elif cp.stage == "developing" and not cp.progress: cp.progress = 30 if not cp.region: cp.region = infer_region(cp.name) if cp.remark and not cp.progress: p = _guess_progress(cp.remark) if p is not None: cp.progress = p total = len(by_name) if args.dry_run: db.rollback() print(f"[dry-run] 将生成定开项目 {total} 个(台账 {created_from_ledger} + 子任务 {created_from_task}),迁移执行记录 {moved_updates} 条") return db.commit() amount_sum = sum(c.amount or 0 for c in by_name.values()) print(f"[ok] 定开项目 {total} 个(台账 {created_from_ledger} / 子任务 {created_from_task})") print(f"[ok] 迁移执行记录 {moved_updates} 条 → custom_updates") print(f"[ok] 合同金额合计 ¥{amount_sum:,.2f}") from collections import Counter print("[ok] 阶段分布:", dict(Counter(c.stage for c in by_name.values()))) print(f"[ok] 常规项目剩余 {db.query(Project).count()} 个(已移除「{CONTAINER}」)") db.close() if __name__ == "__main__": main()