Files
shanxia2.0/backend/scripts/migrate_crm_opportunity.py
zlwuandCursor 10c3c5470c chore: sync local PL/CRM/BX and migration work to new Gogs host
Bundle pending backend/web-admin feature updates, data-migration scripts,
and related docs so the V1.5 branch on the Tencent Cloud Gogs matches the
current workspace (excluding local secrets).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 09:19:21 +08:00

607 lines
23 KiB
Python

#!/usr/bin/env python3
"""将 sx 库 CRM 商机相关表迁入 shanxia2.0。
顺序:crm_opportunity → crm_opportunity_follow → crm_opportunity_product → crm_opportunity_stage_log
用户外键经 user_mapping 转换;附件 OSS ID → 目标 sys_oss 相对 URL;业务 ID 原样 str(id)。
用法:
cd backend
venv/bin/python scripts/migrate_crm_opportunity.py --dry-run --env-file .env.production
venv/bin/python scripts/migrate_crm_opportunity.py --env-file .env.production
"""
from __future__ import annotations
import argparse
import csv
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse, urlunparse
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
BACKEND_ROOT = Path(__file__).resolve().parents[1]
MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
if str(MIGRATION_DIR) not in sys.path:
sys.path.insert(0, str(MIGRATION_DIR))
from minio_url import resolve_oss_id_csv # noqa: E402
DEFAULT_TENANT_NO = "shanxia2.0"
CRM_ADMIN_SOURCE_USER_ID = 1
FOLLOW_TYPE_MAP = {
"电话": "phone",
"phone": "phone",
"微信": "wechat",
"wechat": "wechat",
"邮件": "email",
"email": "email",
"拜访": "visit",
"visit": "visit",
"会议": "meeting",
"meeting": "meeting",
"其他": "other",
"other": "other",
}
MIGRATION_STEPS = (
("crm_opportunity", "opportunity"),
("crm_opportunity_follow", "opportunity_follow"),
("crm_opportunity_product", "opportunity_product"),
("crm_opportunity_stage_log", "opportunity_stage_log"),
)
@dataclass
class StepStats:
source_count: int = 0
inserted: int = 0
updated: int = 0
skipped: int = 0
@dataclass
class MigrationStats:
steps: Dict[str, StepStats] = field(default_factory=dict)
warnings: List[str] = field(default_factory=list)
def step(self, name: str) -> StepStats:
if name not in self.steps:
self.steps[name] = StepStats()
return self.steps[name]
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
if legacy_id is None or str(legacy_id).strip() == "":
return None
try:
return str(int(legacy_id))
except (TypeError, ValueError):
return str(legacy_id).strip()
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
def load_settings_env(env_file: Optional[str]) -> None:
if env_file:
os.environ["ENV_FILE"] = env_file
from dotenv import load_dotenv
candidates: List[Path] = []
if env_file:
p = Path(env_file)
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
for path in candidates:
if path.is_file():
load_dotenv(path, override=True)
break
def load_runtime_settings():
from app.core.config import Settings
return Settings()
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
crm_map: Dict[int, str] = {}
admin_id: Optional[str] = None
with path.open(encoding="utf-8-sig", newline="") as f:
for row in csv.DictReader(f):
new_id = (row.get("new_user_id") or "").strip()
user_no = (row.get("user_no") or "").strip()
crm_raw = (row.get("crm_user_id") or "").strip()
if user_no == "admin" and new_id:
admin_id = new_id
if crm_raw and new_id:
try:
crm_map[int(crm_raw)] = new_id
except ValueError:
pass
return crm_map, admin_id
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
with target_engine.connect() as conn:
row = conn.execute(
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
).first()
return str(row[0]) if row and row[0] else None
def resolve_portal_user_id(
source_user_id: Any,
crm_user_map: Dict[int, str],
admin_id: Optional[str],
stats: MigrationStats,
label: str,
) -> Optional[str]:
if source_user_id is None or str(source_user_id).strip() == "":
return None
try:
uid = int(source_user_id)
except (TypeError, ValueError):
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
return None
if uid == CRM_ADMIN_SOURCE_USER_ID:
return admin_id
mapped = crm_user_map.get(uid)
if not mapped:
stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
return mapped
def load_oss_id_to_url(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
with target_engine.connect() as conn:
rows = conn.execute(
text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
{"t": tenant_no},
).mappings().all()
return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
def normalize_follow_type(raw: Any) -> str:
value = (str(raw).strip() if raw is not None else "") or "other"
return FOLLOW_TYPE_MAP.get(value, FOLLOW_TYPE_MAP.get(value.lower(), "other"))
def _dec(value: Any, default: Optional[Decimal] = None) -> Optional[Decimal]:
if value is None or value == "":
return default
return Decimal(str(value))
def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
if value is None:
return None
s = str(value).strip()
if not s:
return None
return s[:max_len] if max_len else s
def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
where = "" if include_deleted else "WHERE del_flag = 0"
sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
with engine.connect() as conn:
return [dict(r._mapping) for r in conn.execute(text(sql))]
def target_row_exists(conn, table: str, row_id: str) -> bool:
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
def transform_opportunity(
row: dict,
tenant_no: str,
crm_user_map: Dict[int, str],
admin_id: Optional[str],
stats: MigrationStats,
) -> dict:
rid = legacy_id_to_str(row["id"])
label = f"opportunity#{rid}"
def map_user(field_val: Any) -> Optional[str]:
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
return {
"id": rid,
"opportunity_no": (row.get("opportunity_no") or "")[:50],
"opportunity_name": (row.get("opportunity_name") or "")[:200],
"customer_id": legacy_id_to_str(row.get("customer_id")) or "",
"customer_name": _str_or_none(row.get("customer_name"), 200),
"contact_id": legacy_id_to_str(row.get("contact_id")),
"contact_name": _str_or_none(row.get("contact_name"), 50),
"stage": (row.get("stage") or "initial")[:20],
"stage_percent": int(row.get("stage_percent") or 10),
"status": (row.get("status") or "following")[:20],
"win_rate": _dec(row.get("win_rate"), Decimal("50.00")),
"amount": _dec(row.get("amount"), Decimal("0.00")),
"expect_deal_date": row.get("expect_deal_date"),
"actual_deal_date": row.get("actual_deal_date"),
"lost_reason": _str_or_none(row.get("lost_reason"), 500),
"owner_user_id": map_user(row.get("owner_user_id")),
"owner_user_name": _str_or_none(row.get("owner_user_name"), 50),
"product_count": int(row.get("product_count") or 0),
"last_time": row.get("last_time"),
"next_time": row.get("next_time"),
"follow_count": int(row.get("follow_count") or 0),
"contract_id": legacy_id_to_str(row.get("contract_id")),
"source": _str_or_none(row.get("source"), 20),
"type": _str_or_none(row.get("type"), 20),
"description": row.get("description"),
"competitors": _str_or_none(row.get("competitors"), 500),
"key_factors": _str_or_none(row.get("key_factors"), 500),
"tenant_no": tenant_no,
"order_num": 0,
"remark": _str_or_none(row.get("remark"), 512),
"create_by": map_user(row.get("create_by")),
"create_dept_id": "",
"create_time": row.get("create_time") or datetime.now(),
"update_by": map_user(row.get("update_by")),
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
"del_flag": int(row.get("del_flag") or 0),
}
def transform_opportunity_follow(
row: dict,
tenant_no: str,
crm_user_map: Dict[int, str],
admin_id: Optional[str],
oss_map: Dict[str, str],
stats: MigrationStats,
) -> dict:
rid = legacy_id_to_str(row["id"])
label = f"opportunity_follow#{rid}"
owner_id = resolve_portal_user_id(row.get("create_by"), crm_user_map, admin_id, stats, label)
follow_by = resolve_portal_user_id(row.get("follow_by"), crm_user_map, admin_id, stats, label)
return {
"id": rid,
"opportunity_id": legacy_id_to_str(row.get("opportunity_id")) or "",
"follow_type": normalize_follow_type(row.get("follow_type")),
"follow_content": row.get("follow_content") or "",
"follow_time": row.get("follow_time") or row.get("create_time"),
"stage": _str_or_none(row.get("stage"), 20),
"stage_percent": int(row["stage_percent"]) if row.get("stage_percent") is not None else None,
"next_time": row.get("next_time"),
"next_content": _str_or_none(row.get("next_content"), 500),
"attachments": resolve_oss_id_csv(row.get("attachments"), oss_map) or None,
"contact_id": legacy_id_to_str(row.get("contact_id")),
"contact_name": _str_or_none(row.get("contact_name"), 50),
"location": _str_or_none(row.get("location"), 200),
"duration": int(row["duration"]) if row.get("duration") is not None else None,
"follow_by": follow_by or owner_id,
"tenant_no": tenant_no,
"order_num": 0,
"remark": _str_or_none(row.get("remark"), 500),
"create_by": owner_id,
"create_dept_id": "",
"create_time": row.get("create_time") or datetime.now(),
"update_by": resolve_portal_user_id(row.get("update_by"), crm_user_map, admin_id, stats, label),
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
"del_flag": int(row.get("del_flag") or 0),
}
def transform_opportunity_product(
row: dict,
tenant_no: str,
crm_user_map: Dict[int, str],
admin_id: Optional[str],
stats: MigrationStats,
) -> dict:
rid = legacy_id_to_str(row["id"])
label = f"opportunity_product#{rid}"
def map_user(field_val: Any) -> Optional[str]:
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
return {
"id": rid,
"opportunity_id": legacy_id_to_str(row.get("opportunity_id")) or "",
"product_id": legacy_id_to_str(row.get("product_id")) or "",
"product_name": _str_or_none(row.get("product_name"), 200),
"product_no": _str_or_none(row.get("product_no"), 50),
"category_id": legacy_id_to_str(row.get("category_id")),
"category_name": _str_or_none(row.get("category_name"), 100),
"model": _str_or_none(row.get("model"), 100),
"unit": (row.get("unit") or "件")[:20],
"quantity": _dec(row.get("quantity"), Decimal("0")),
"price": _dec(row.get("price"), Decimal("0.00")),
"discount": _dec(row.get("discount"), Decimal("0.00")),
"subtotal": _dec(row.get("subtotal"), Decimal("0.00")),
"sort": int(row.get("sort") or 0),
"tenant_no": tenant_no,
"order_num": 0,
"remark": _str_or_none(row.get("remark"), 500),
"create_by": map_user(row.get("create_by")),
"create_dept_id": "",
"create_time": row.get("create_time") or datetime.now(),
"update_by": map_user(row.get("update_by")),
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
"del_flag": int(row.get("del_flag") or 0),
}
def transform_opportunity_stage_log(
row: dict,
tenant_no: str,
crm_user_map: Dict[int, str],
admin_id: Optional[str],
stats: MigrationStats,
) -> dict:
rid = legacy_id_to_str(row["id"])
label = f"opportunity_stage_log#{rid}"
def map_user(field_val: Any) -> Optional[str]:
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
return {
"id": rid,
"opportunity_id": legacy_id_to_str(row.get("opportunity_id")) or "",
"from_stage": _str_or_none(row.get("from_stage"), 20),
"from_stage_name": _str_or_none(row.get("from_stage_name"), 50),
"from_percent": int(row["from_percent"]) if row.get("from_percent") is not None else None,
"to_stage": (row.get("to_stage") or "")[:20],
"to_stage_name": _str_or_none(row.get("to_stage_name"), 50),
"to_percent": int(row["to_percent"]) if row.get("to_percent") is not None else None,
"change_type": (row.get("change_type") or "forward")[:20],
"change_reason": _str_or_none(row.get("change_reason"), 500),
"change_amount": _dec(row.get("change_amount")),
"lost_detail": _str_or_none(row.get("lost_detail"), 2000),
"competitor_info": _str_or_none(row.get("competitor_info"), 1000),
"improvement": _str_or_none(row.get("improvement"), 1000),
"won_remark": _str_or_none(row.get("won_remark"), 2000),
"tenant_no": tenant_no,
"order_num": 0,
"remark": _str_or_none(row.get("remark"), 500),
"create_by": map_user(row.get("create_by")),
"create_dept_id": "",
"create_time": row.get("create_time") or datetime.now(),
"update_by": map_user(row.get("update_by")),
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
"del_flag": int(row.get("del_flag") or 0),
}
INSERT_OPPORTUNITY_SQL = text(
"""
INSERT INTO crm_opportunity (
id, opportunity_no, opportunity_name, customer_id, customer_name, contact_id, contact_name,
stage, stage_percent, status, win_rate, amount, expect_deal_date, actual_deal_date, lost_reason,
owner_user_id, owner_user_name, product_count, last_time, next_time, follow_count, contract_id,
source, type, description, competitors, key_factors,
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
) VALUES (
:id, :opportunity_no, :opportunity_name, :customer_id, :customer_name, :contact_id, :contact_name,
:stage, :stage_percent, :status, :win_rate, :amount, :expect_deal_date, :actual_deal_date, :lost_reason,
:owner_user_id, :owner_user_name, :product_count, :last_time, :next_time, :follow_count, :contract_id,
:source, :type, :description, :competitors, :key_factors,
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
)
"""
)
INSERT_OPPORTUNITY_FOLLOW_SQL = text(
"""
INSERT INTO crm_opportunity_follow (
id, opportunity_id, follow_type, follow_content, follow_time, stage, stage_percent,
next_time, next_content, attachments, contact_id, contact_name, location, duration, follow_by,
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
) VALUES (
:id, :opportunity_id, :follow_type, :follow_content, :follow_time, :stage, :stage_percent,
:next_time, :next_content, :attachments, :contact_id, :contact_name, :location, :duration, :follow_by,
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
)
"""
)
INSERT_OPPORTUNITY_PRODUCT_SQL = text(
"""
INSERT INTO crm_opportunity_product (
id, opportunity_id, product_id, product_name, product_no, category_id, category_name,
model, unit, quantity, price, discount, subtotal, sort,
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
) VALUES (
:id, :opportunity_id, :product_id, :product_name, :product_no, :category_id, :category_name,
:model, :unit, :quantity, :price, :discount, :subtotal, :sort,
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
)
"""
)
INSERT_OPPORTUNITY_STAGE_LOG_SQL = text(
"""
INSERT INTO crm_opportunity_stage_log (
id, opportunity_id, from_stage, from_stage_name, from_percent, to_stage, to_stage_name, to_percent,
change_type, change_reason, change_amount, lost_detail, competitor_info, improvement, won_remark,
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
) VALUES (
:id, :opportunity_id, :from_stage, :from_stage_name, :from_percent, :to_stage, :to_stage_name, :to_percent,
:change_type, :change_reason, :change_amount, :lost_detail, :competitor_info, :improvement, :won_remark,
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
)
"""
)
STEP_INSERT_SQL = {
"crm_opportunity": INSERT_OPPORTUNITY_SQL,
"crm_opportunity_follow": INSERT_OPPORTUNITY_FOLLOW_SQL,
"crm_opportunity_product": INSERT_OPPORTUNITY_PRODUCT_SQL,
"crm_opportunity_stage_log": INSERT_OPPORTUNITY_STAGE_LOG_SQL,
}
def write_mapping_csv(source_rows: List[dict], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
w.writeheader()
for row in source_rows:
sid = legacy_id_to_str(row["id"])
w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
def migrate_table(
source_engine: Engine,
target_engine: Engine,
table: str,
*,
tenant_no: str,
crm_user_map: Dict[int, str],
admin_id: Optional[str],
oss_map: Dict[str, str],
stats: MigrationStats,
dry_run: bool,
update_existing: bool,
include_deleted: bool,
) -> List[dict]:
step = stats.step(table)
source_rows = fetch_source_rows(source_engine, table, include_deleted)
step.source_count = len(source_rows)
transformers = {
"crm_opportunity": lambda r: transform_opportunity(r, tenant_no, crm_user_map, admin_id, stats),
"crm_opportunity_follow": lambda r: transform_opportunity_follow(
r, tenant_no, crm_user_map, admin_id, oss_map, stats
),
"crm_opportunity_product": lambda r: transform_opportunity_product(
r, tenant_no, crm_user_map, admin_id, stats
),
"crm_opportunity_stage_log": lambda r: transform_opportunity_stage_log(
r, tenant_no, crm_user_map, admin_id, stats
),
}
payloads = [transformers[table](r) for r in source_rows]
if dry_run:
step.inserted = len(payloads)
return source_rows
insert_sql = STEP_INSERT_SQL[table]
with target_engine.begin() as conn:
for payload in payloads:
rid = payload["id"]
if target_row_exists(conn, table, rid):
if update_existing:
step.skipped += 1
else:
step.skipped += 1
else:
conn.execute(insert_sql, payload)
step.inserted += 1
return source_rows
def print_report(stats: MigrationStats, dry_run: bool) -> None:
mode = "DRY-RUN" if dry_run else "COMMITTED"
print(f"\n=== CRM 商机四表迁移 [{mode}] ===")
for table, _ in MIGRATION_STEPS:
s = stats.step(table)
print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
if stats.warnings:
print(f"警告 ({len(stats.warnings)}):")
for w in stats.warnings[:20]:
print(f" - {w}")
if len(stats.warnings) > 20:
print(f" ... 另有 {len(stats.warnings) - 20} 条")
def run_migration(
source_engine: Engine,
target_engine: Engine,
*,
tenant_no: str,
user_mapping_path: Path,
mapping_dir: Path,
dry_run: bool,
update_existing: bool,
include_deleted: bool,
only_tables: Optional[List[str]],
) -> MigrationStats:
stats = MigrationStats()
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
if not admin_id:
admin_id = fetch_portal_admin_id(target_engine)
oss_map = load_oss_id_to_url(target_engine, tenant_no)
print(f"sys_oss 映射条数: {len(oss_map)}")
selected = [t for t, _ in MIGRATION_STEPS if not only_tables or t in only_tables]
for table in selected:
print(f"\n>> 迁移 {table} ...")
source_rows = migrate_table(
source_engine,
target_engine,
table,
tenant_no=tenant_no,
crm_user_map=crm_user_map,
admin_id=admin_id,
oss_map=oss_map,
stats=stats,
dry_run=dry_run,
update_existing=update_existing,
include_deleted=include_deleted,
)
if table == "crm_opportunity" and not dry_run:
write_mapping_csv(source_rows, mapping_dir / "crm_opportunity_id_mapping.csv")
print_report(stats, dry_run)
return stats
def main() -> int:
parser = argparse.ArgumentParser(description="Migrate CRM opportunity tables from sx to portal")
parser.add_argument("--source-url", default="")
parser.add_argument("--target-url", default="")
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
parser.add_argument(
"--user-mapping",
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--update", action="store_true")
parser.add_argument("--include-deleted", action="store_true")
parser.add_argument("--tables", default="", help="逗号分隔,默认全部四表")
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
args = parser.parse_args()
load_settings_env(args.env_file or None)
settings = load_runtime_settings()
target_url = args.target_url or settings.DATABASE_URL
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
print(f"源库: {urlparse(source_url).path}")
print(f"目标库: {urlparse(target_url).path}")
run_migration(
create_engine(source_url, pool_pre_ping=True),
create_engine(target_url, pool_pre_ping=True),
tenant_no=args.tenant_no,
user_mapping_path=Path(args.user_mapping),
mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
dry_run=args.dry_run,
update_existing=args.update,
include_deleted=args.include_deleted,
only_tables=only_tables,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())