| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393 |
- #!/usr/bin/env python3
- """将 sx 库 CRM 回款/开票相关表迁入 shanxia2.0。
- 门户侧当前仅有 crm_invoice_info 表;源库回款/发票主表及明细均为空,跳过。
- 实际迁移:crm_invoice_info(客户开票信息子表)。
- 用法:
- cd backend
- venv/bin/python scripts/migrate_crm_payment_invoice.py --dry-run --env-file .env.production
- venv/bin/python scripts/migrate_crm_payment_invoice.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 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]
- if str(BACKEND_ROOT) not in sys.path:
- sys.path.insert(0, str(BACKEND_ROOT))
- DEFAULT_TENANT_NO = "shanxia2.0"
- CRM_ADMIN_SOURCE_USER_ID = 1
- # 源库存在但门户暂无对应业务表,或源库无数据
- SKIPPED_TABLES = (
- "crm_payment",
- "crm_payment_plan",
- "crm_payment_collection",
- "crm_invoice",
- "crm_invoice_item",
- )
- @dataclass
- class StepStats:
- source_count: int = 0
- inserted: 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 _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 count_source_rows(engine: Engine, table: str) -> int:
- with engine.connect() as conn:
- try:
- return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE del_flag=0")).scalar_one())
- except Exception:
- return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}`")).scalar_one())
- def target_table_exists(engine: Engine, table: str) -> bool:
- with engine.connect() as conn:
- row = conn.execute(text("SHOW TABLES LIKE :t"), {"t": table}).first()
- return row is not None
- 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_invoice_info(
- row: dict,
- tenant_no: str,
- crm_user_map: Dict[int, str],
- admin_id: Optional[str],
- stats: MigrationStats,
- ) -> Optional[dict]:
- rid = legacy_id_to_str(row["id"])
- label = f"invoice_info#{rid}"
- customer_id = legacy_id_to_str(row.get("customer_id"))
- if not customer_id:
- stats.warnings.append(f"{label}: 缺少 customer_id,已跳过")
- return None
- company_name = _str_or_none(row.get("company_name"), 200)
- if not company_name:
- stats.warnings.append(f"{label}: company_name 为空,已跳过")
- return None
- def map_user(field_val: Any) -> Optional[str]:
- return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
- is_default = (str(row.get("is_default") or "0").strip() or "0")[:1]
- if is_default not in ("0", "1"):
- is_default = "1" if is_default in ("true", "True", "Y", "y") else "0"
- return {
- "id": rid,
- "customer_id": customer_id,
- "company_name": company_name,
- "tax_no": None,
- "company_address": _str_or_none(row.get("company_address"), 500),
- "legal_representative": _str_or_none(row.get("legal_representative"), 100),
- "agent": _str_or_none(row.get("agent"), 100),
- "phone_fax": _str_or_none(row.get("phone_fax"), 100),
- "bank_name": _str_or_none(row.get("bank_name"), 200),
- "bank_account": _str_or_none(row.get("bank_account"), 100),
- "is_default": is_default,
- "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),
- }
- INSERT_INVOICE_INFO_SQL = text(
- """
- INSERT INTO crm_invoice_info (
- id, customer_id, company_name, tax_no, company_address, legal_representative,
- agent, phone_fax, bank_name, bank_account, is_default,
- tenant_no, order_num, remark, create_by, create_dept_id,
- create_time, update_by, update_time, del_flag
- ) VALUES (
- :id, :customer_id, :company_name, :tax_no, :company_address, :legal_representative,
- :agent, :phone_fax, :bank_name, :bank_account, :is_default,
- :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
- :create_time, :update_by, :update_time, :del_flag
- )
- """
- )
- def write_mapping_csv(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 rows:
- sid = legacy_id_to_str(row["id"])
- w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
- def report_skipped_tables(source_engine: Engine, target_engine: Engine, stats: MigrationStats) -> None:
- print("\n>> 跳过表(门户无表或源库无数据):")
- for table in SKIPPED_TABLES:
- source_count = count_source_rows(source_engine, table)
- target_exists = target_table_exists(target_engine, table)
- step = stats.step(table)
- step.source_count = source_count
- if source_count == 0:
- print(f" {table}: 源库 0 行,跳过")
- elif not target_exists:
- print(f" {table}: 源库 {source_count} 行,门户无对应表,跳过")
- stats.warnings.append(f"{table}: 源库有 {source_count} 行但门户无表,未迁移")
- else:
- print(f" {table}: 源={source_count} 目标表存在(本脚本未实现,需单独处理)")
- def migrate_invoice_info(
- source_engine: Engine,
- target_engine: Engine,
- *,
- tenant_no: str,
- crm_user_map: Dict[int, str],
- admin_id: Optional[str],
- stats: MigrationStats,
- dry_run: bool,
- mapping_dir: Path,
- ) -> None:
- table = "crm_invoice_info"
- step = stats.step(table)
- source_rows = fetch_source_rows(source_engine, table, include_deleted=False)
- step.source_count = len(source_rows)
- payloads: List[dict] = []
- for row in source_rows:
- payload = transform_invoice_info(row, tenant_no, crm_user_map, admin_id, stats)
- if payload:
- payloads.append(payload)
- else:
- step.skipped += 1
- if dry_run:
- step.inserted = len(payloads)
- print(f"{table}: 源={step.source_count} 可插入={step.inserted} 跳过={step.skipped}")
- return
- with target_engine.begin() as conn:
- for payload in payloads:
- rid = payload["id"]
- if target_row_exists(conn, table, rid):
- step.skipped += 1
- else:
- conn.execute(INSERT_INVOICE_INFO_SQL, payload)
- step.inserted += 1
- write_mapping_csv(source_rows, mapping_dir / "crm_invoice_info_id_mapping.csv")
- print(f"{table}: 源={step.source_count} 插入={step.inserted} 跳过={step.skipped}")
- def print_report(stats: MigrationStats, dry_run: bool) -> None:
- mode = "DRY-RUN" if dry_run else "COMMITTED"
- print(f"\n=== CRM 回款/开票迁移 [{mode}] ===")
- for name in (*SKIPPED_TABLES, "crm_invoice_info"):
- s = stats.step(name)
- if s.source_count or s.inserted or s.skipped:
- print(f"{name}: 源={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,
- ) -> 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)
- report_skipped_tables(source_engine, target_engine, stats)
- print("\n>> 迁移 crm_invoice_info ...")
- migrate_invoice_info(
- source_engine,
- target_engine,
- tenant_no=tenant_no,
- crm_user_map=crm_user_map,
- admin_id=admin_id,
- stats=stats,
- dry_run=dry_run,
- mapping_dir=mapping_dir,
- )
- print_report(stats, dry_run)
- return stats
- def main() -> int:
- parser = argparse.ArgumentParser(description="Migrate CRM payment/invoice bundle 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("--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)
- 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,
- )
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|