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>
843 lines
36 KiB
Python
843 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
"""将 sx 库 CRM 合同相关表迁入 shanxia2.0。
|
|
|
|
顺序:crm_contract_template → crm_contract → crm_contract_product →
|
|
crm_contract_product_price_tier → crm_contract_approval → crm_contract_change
|
|
用户外键经 user_mapping;附件 OSS ID / URL 规范化;attachment_files JSON 保留 ossId。
|
|
|
|
用法:
|
|
cd backend
|
|
venv/bin/python scripts/migrate_crm_contract.py --dry-run --env-file .env.production
|
|
venv/bin/python scripts/migrate_crm_contract.py --env-file .env.production
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
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 extract_minio_object_key, join_relative_urls, resolve_oss_id_csv # noqa: E402
|
|
|
|
DEFAULT_TENANT_NO = "shanxia2.0"
|
|
CRM_ADMIN_SOURCE_USER_ID = 1
|
|
|
|
MIGRATION_STEPS = (
|
|
("crm_contract_template", "contract_template"),
|
|
("crm_contract", "contract"),
|
|
("crm_contract_product", "contract_product"),
|
|
("crm_contract_product_price_tier", "contract_product_price_tier"),
|
|
("crm_contract_approval", "contract_approval"),
|
|
("crm_contract_change", "contract_change"),
|
|
)
|
|
|
|
|
|
@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,
|
|
*,
|
|
required: bool = False,
|
|
) -> Optional[str]:
|
|
if source_user_id is None or str(source_user_id).strip() == "":
|
|
return admin_id if required else None
|
|
try:
|
|
uid = int(source_user_id)
|
|
except (TypeError, ValueError):
|
|
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
|
return admin_id if required else 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 admin_id if required else None
|
|
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_attachment_csv(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
|
|
if raw is None or str(raw).strip() == "":
|
|
return None
|
|
oss_resolved = resolve_oss_id_csv(raw, oss_map)
|
|
if oss_resolved:
|
|
return oss_resolved
|
|
parts: List[str] = []
|
|
for token in str(raw).split(","):
|
|
token = token.strip()
|
|
if not token:
|
|
continue
|
|
if token.isdigit() and token in oss_map:
|
|
parts.append(oss_map[token])
|
|
else:
|
|
key = extract_minio_object_key(token)
|
|
if key:
|
|
parts.append(key)
|
|
joined = join_relative_urls(parts)
|
|
return joined or None
|
|
|
|
|
|
def normalize_contract_attachment_files(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
|
|
if raw is None or str(raw).strip() == "":
|
|
return None
|
|
value = str(raw).strip()
|
|
if value.startswith("["):
|
|
try:
|
|
parsed = json.loads(value)
|
|
if isinstance(parsed, list):
|
|
out: List[dict] = []
|
|
for item in parsed:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
oss_id = legacy_id_to_str(item.get("ossId") or item.get("oss_id"))
|
|
if not oss_id:
|
|
continue
|
|
# 门户约定 ossId 字段存 MinIO 相对路径;迁入时优先展开为 url
|
|
file_key = oss_map.get(oss_id) or oss_id
|
|
out.append({"ossId": file_key, "category": item.get("category") or "other"})
|
|
if out:
|
|
return json.dumps(out, ensure_ascii=False)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return normalize_attachment_csv(raw, oss_map)
|
|
|
|
|
|
def normalize_file_path(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
|
|
if raw is None or str(raw).strip() == "":
|
|
return None
|
|
value = str(raw).strip()
|
|
if value.isdigit() and value in oss_map:
|
|
return oss_map[value]
|
|
key = extract_minio_object_key(value)
|
|
return key or None
|
|
|
|
|
|
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_contract_template(
|
|
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"contract_template#{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,
|
|
"template_no": (row.get("template_no") or "")[:50],
|
|
"template_name": (row.get("template_name") or "")[:200],
|
|
"contract_type": _str_or_none(row.get("contract_type"), 20),
|
|
"template_content": row.get("template_content"),
|
|
"payment_terms": row.get("payment_terms"),
|
|
"delivery_terms": row.get("delivery_terms"),
|
|
"contract_terms": row.get("contract_terms"),
|
|
"party_b": _str_or_none(row.get("party_b"), 200),
|
|
"party_b_address": _str_or_none(row.get("party_b_address"), 255),
|
|
"party_b_contact": _str_or_none(row.get("party_b_contact"), 50),
|
|
"party_b_mobile": _str_or_none(row.get("party_b_mobile"), 20),
|
|
"default_currency": (row.get("default_currency") or "CNY")[:10],
|
|
"default_tax_rate": _dec(row.get("default_tax_rate"), Decimal("6.00")),
|
|
"default_period": int(row.get("default_period") or 12),
|
|
"is_active": int(row.get("is_active") if row.get("is_active") is not None else 1),
|
|
"usage_count": int(row.get("usage_count") or 0),
|
|
"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_contract(
|
|
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"contract#{rid}"
|
|
|
|
def map_user(field_val: Any, *, required: bool = False) -> Optional[str]:
|
|
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label, required=required)
|
|
|
|
owner_id = map_user(row.get("owner_user_id"), required=True)
|
|
|
|
return {
|
|
"id": rid,
|
|
"contract_no": (row.get("contract_no") or "")[:50],
|
|
"contract_name": (row.get("contract_name") or "")[:200],
|
|
"customer_id": legacy_id_to_str(row.get("customer_id")) or "",
|
|
"customer_name": _str_or_none(row.get("customer_name"), 200),
|
|
"opportunity_id": legacy_id_to_str(row.get("opportunity_id")),
|
|
"quote_id": legacy_id_to_str(row.get("quote_id")),
|
|
"parent_contract_id": legacy_id_to_str(row.get("parent_contract_id")),
|
|
"renewal_count": int(row.get("renewal_count") or 0),
|
|
"template_id": legacy_id_to_str(row.get("template_id")),
|
|
"contract_template": (row.get("contract_template") or "shanxia")[:50],
|
|
"contact_id": legacy_id_to_str(row.get("contact_id")),
|
|
"contact_name": _str_or_none(row.get("contact_name"), 50),
|
|
"contact_mobile": _str_or_none(row.get("contact_mobile"), 20),
|
|
"contract_type": (row.get("contract_type") or "sale")[:20],
|
|
"contract_nature": (row.get("contract_nature") or "new")[:20],
|
|
"amount": _dec(row.get("amount"), Decimal("0.00")),
|
|
"currency": (row.get("currency") or "CNY")[:10],
|
|
"tax_rate": _dec(row.get("tax_rate"), Decimal("6.00")),
|
|
"amount_with_tax": _dec(row.get("amount_with_tax"), Decimal("0.00")),
|
|
"status": (row.get("status") or "draft")[:20],
|
|
"performance_status": (row.get("performance_status") or "normal")[:20],
|
|
"performance_rate": _dec(row.get("performance_rate"), Decimal("0.00")),
|
|
"customer_satisfaction": int(row["customer_satisfaction"]) if row.get("customer_satisfaction") is not None else None,
|
|
"approval_status": _str_or_none(row.get("approval_status"), 20),
|
|
"approval_flow_id": legacy_id_to_str(row.get("approval_flow_id")),
|
|
"contract_file": normalize_file_path(row.get("contract_file"), oss_map),
|
|
"attachment_files": normalize_contract_attachment_files(row.get("attachment_files"), oss_map),
|
|
"sign_date": row.get("sign_date"),
|
|
"actual_sign_date": row.get("actual_sign_date"),
|
|
"start_date": row.get("start_date"),
|
|
"end_date": row.get("end_date"),
|
|
"contract_period": int(row["contract_period"]) if row.get("contract_period") is not None else None,
|
|
"owner_user_id": owner_id or "",
|
|
"owner_user_name": _str_or_none(row.get("owner_user_name"), 50),
|
|
"received_amount": _dec(row.get("received_amount"), Decimal("0.00")),
|
|
"unreceived_amount": _dec(row.get("unreceived_amount"), Decimal("0.00")),
|
|
"invoice_amount": _dec(row.get("invoice_amount"), Decimal("0.00")),
|
|
"uninvoice_amount": _dec(row.get("uninvoice_amount"), Decimal("0.00")),
|
|
"party_a": _str_or_none(row.get("party_a"), 200),
|
|
"party_a_address": _str_or_none(row.get("party_a_address"), 255),
|
|
"party_a_contact": _str_or_none(row.get("party_a_contact"), 50),
|
|
"party_a_mobile": _str_or_none(row.get("party_a_mobile"), 20),
|
|
"party_a_legal_person": _str_or_none(row.get("party_a_legal_person"), 50),
|
|
"party_a_agent": _str_or_none(row.get("party_a_agent"), 50),
|
|
"party_a_phone_fax": _str_or_none(row.get("party_a_phone_fax"), 50),
|
|
"party_a_bank": _str_or_none(row.get("party_a_bank"), 100),
|
|
"party_a_account": _str_or_none(row.get("party_a_account"), 50),
|
|
"party_b": _str_or_none(row.get("party_b"), 200),
|
|
"party_b_address": _str_or_none(row.get("party_b_address"), 255),
|
|
"party_b_contact": _str_or_none(row.get("party_b_contact"), 50),
|
|
"party_b_mobile": _str_or_none(row.get("party_b_mobile"), 20),
|
|
"party_b_legal_person": _str_or_none(row.get("party_b_legal_person"), 100),
|
|
"party_b_agent": _str_or_none(row.get("party_b_agent"), 100),
|
|
"party_b_phone_fax": _str_or_none(row.get("party_b_phone_fax"), 100),
|
|
"party_b_bank": _str_or_none(row.get("party_b_bank"), 200),
|
|
"party_b_account": _str_or_none(row.get("party_b_account"), 100),
|
|
"payment_terms": row.get("payment_terms"),
|
|
"delivery_terms": row.get("delivery_terms"),
|
|
"contract_terms": row.get("contract_terms"),
|
|
"settlement_method": _str_or_none(row.get("settlement_method"), 50),
|
|
"template_content": row.get("template_content"),
|
|
"settlement_clause_drafts": row.get("settlement_clause_drafts"),
|
|
"invalid_reason": _str_or_none(row.get("invalid_reason"), 500),
|
|
"invalid_time": row.get("invalid_time"),
|
|
"version_no": int(row.get("version_no") if row.get("version_no") is not None else row.get("version") or 0),
|
|
"supplier_id": legacy_id_to_str(row.get("supplier_id")),
|
|
"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_contract_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"contract_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,
|
|
"contract_id": legacy_id_to_str(row.get("contract_id")) or "",
|
|
"product_id": legacy_id_to_str(row.get("product_id")) or "",
|
|
"product_name": (row.get("product_name") or "")[: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")),
|
|
"tax_rate": _dec(row.get("tax_rate"), Decimal("6.00")),
|
|
"tax_amount": _dec(row.get("tax_amount"), Decimal("0.00")),
|
|
"subtotal": _dec(row.get("subtotal"), Decimal("0.00")),
|
|
"delivery_date": row.get("delivery_date"),
|
|
"delivery_status": (row.get("delivery_status") or "pending")[:20],
|
|
"delivered_quantity": _dec(row.get("delivered_quantity"), Decimal("0")),
|
|
"description": _str_or_none(row.get("description"), 1000),
|
|
"package_spec": _str_or_none(row.get("package_spec"), 200),
|
|
"delivery_method": _str_or_none(row.get("delivery_method"), 100),
|
|
"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_contract_price_tier(
|
|
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"contract_price_tier#{rid}"
|
|
|
|
def map_user(field_val: Any) -> Optional[str]:
|
|
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
|
|
|
letter_oss_ids = row.get("letter_oss_ids")
|
|
letter_oss_id = legacy_id_to_str(row.get("letter_oss_id"))
|
|
|
|
return {
|
|
"id": rid,
|
|
"contract_id": legacy_id_to_str(row.get("contract_id")) or "",
|
|
"contract_product_id": legacy_id_to_str(row.get("contract_product_id")) or "",
|
|
"start_date": row.get("start_date"),
|
|
"end_date": row.get("end_date"),
|
|
"price": _dec(row.get("price"), Decimal("0")),
|
|
"letter_oss_ids": letter_oss_ids if letter_oss_ids else None,
|
|
"letter_oss_id": letter_oss_id,
|
|
"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_contract_approval(
|
|
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"contract_approval#{rid}"
|
|
|
|
def map_user(field_val: Any, *, required: bool = False) -> Optional[str]:
|
|
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label, required=required)
|
|
|
|
return {
|
|
"id": rid,
|
|
"contract_id": legacy_id_to_str(row.get("contract_id")) or "",
|
|
"flow_id": legacy_id_to_str(row.get("flow_id")),
|
|
"step": int(row.get("step") or 1),
|
|
"step_name": _str_or_none(row.get("step_name"), 100),
|
|
"approver_id": map_user(row.get("approver_id"), required=True) or "",
|
|
"approver_name": _str_or_none(row.get("approver_name"), 50),
|
|
"approver_dept": _str_or_none(row.get("approver_dept"), 100),
|
|
"approval_result": (row.get("approval_result") or "pending")[:20],
|
|
"approval_opinion": row.get("approval_opinion"),
|
|
"approval_time": row.get("approval_time"),
|
|
"approval_duration": int(row["approval_duration"]) if row.get("approval_duration") is not None else None,
|
|
"attachments": normalize_attachment_csv(row.get("attachments"), oss_map),
|
|
"is_current": int(row.get("is_current") if row.get("is_current") is not None else 1),
|
|
"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_contract_change(
|
|
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"contract_change#{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,
|
|
"contract_id": legacy_id_to_str(row.get("contract_id")) or "",
|
|
"contract_no": _str_or_none(row.get("contract_no"), 50),
|
|
"change_type": (row.get("change_type") or "")[:50],
|
|
"change_reason": (row.get("change_reason") or "")[:500],
|
|
"change_content": row.get("change_content"),
|
|
"old_value": row.get("old_value"),
|
|
"new_value": row.get("new_value"),
|
|
"change_amount": _dec(row.get("change_amount")),
|
|
"approval_status": (row.get("approval_status") or "pending")[:20],
|
|
"approver_id": map_user(row.get("approver_id")),
|
|
"approver_name": _str_or_none(row.get("approver_name"), 50),
|
|
"approval_time": row.get("approval_time"),
|
|
"approval_opinion": _str_or_none(row.get("approval_opinion"), 500),
|
|
"attachment_files": normalize_contract_attachment_files(row.get("attachment_files"), oss_map),
|
|
"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_CONTRACT_TEMPLATE_SQL = text(
|
|
"""
|
|
INSERT INTO crm_contract_template (
|
|
id, template_no, template_name, contract_type, template_content, payment_terms,
|
|
delivery_terms, contract_terms, party_b, party_b_address, party_b_contact, party_b_mobile,
|
|
default_currency, default_tax_rate, default_period, is_active, usage_count, sort,
|
|
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
|
) VALUES (
|
|
:id, :template_no, :template_name, :contract_type, :template_content, :payment_terms,
|
|
:delivery_terms, :contract_terms, :party_b, :party_b_address, :party_b_contact, :party_b_mobile,
|
|
:default_currency, :default_tax_rate, :default_period, :is_active, :usage_count, :sort,
|
|
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
|
)
|
|
"""
|
|
)
|
|
|
|
INSERT_CONTRACT_SQL = text(
|
|
"""
|
|
INSERT INTO crm_contract (
|
|
id, contract_no, contract_name, customer_id, customer_name, opportunity_id, quote_id,
|
|
parent_contract_id, renewal_count, template_id, contract_template, contact_id, contact_name,
|
|
contact_mobile, contract_type, contract_nature, amount, currency, tax_rate, amount_with_tax,
|
|
status, performance_status, performance_rate, customer_satisfaction, approval_status,
|
|
approval_flow_id, contract_file, attachment_files, sign_date, actual_sign_date, start_date,
|
|
end_date, contract_period, owner_user_id, owner_user_name, received_amount, unreceived_amount,
|
|
invoice_amount, uninvoice_amount, party_a, party_a_address, party_a_contact, party_a_mobile,
|
|
party_a_legal_person, party_a_agent, party_a_phone_fax, party_a_bank, party_a_account,
|
|
party_b, party_b_address, party_b_contact, party_b_mobile, party_b_legal_person, party_b_agent,
|
|
party_b_phone_fax, party_b_bank, party_b_account, payment_terms, delivery_terms, contract_terms,
|
|
settlement_method, template_content, settlement_clause_drafts, invalid_reason, invalid_time,
|
|
version_no, supplier_id, tenant_no, order_num, remark, create_by, create_dept_id, create_time,
|
|
update_by, update_time, del_flag
|
|
) VALUES (
|
|
:id, :contract_no, :contract_name, :customer_id, :customer_name, :opportunity_id, :quote_id,
|
|
:parent_contract_id, :renewal_count, :template_id, :contract_template, :contact_id, :contact_name,
|
|
:contact_mobile, :contract_type, :contract_nature, :amount, :currency, :tax_rate, :amount_with_tax,
|
|
:status, :performance_status, :performance_rate, :customer_satisfaction, :approval_status,
|
|
:approval_flow_id, :contract_file, :attachment_files, :sign_date, :actual_sign_date, :start_date,
|
|
:end_date, :contract_period, :owner_user_id, :owner_user_name, :received_amount, :unreceived_amount,
|
|
:invoice_amount, :uninvoice_amount, :party_a, :party_a_address, :party_a_contact, :party_a_mobile,
|
|
:party_a_legal_person, :party_a_agent, :party_a_phone_fax, :party_a_bank, :party_a_account,
|
|
:party_b, :party_b_address, :party_b_contact, :party_b_mobile, :party_b_legal_person, :party_b_agent,
|
|
:party_b_phone_fax, :party_b_bank, :party_b_account, :payment_terms, :delivery_terms, :contract_terms,
|
|
:settlement_method, :template_content, :settlement_clause_drafts, :invalid_reason, :invalid_time,
|
|
:version_no, :supplier_id, :tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time,
|
|
:update_by, :update_time, :del_flag
|
|
)
|
|
"""
|
|
)
|
|
|
|
INSERT_CONTRACT_PRODUCT_SQL = text(
|
|
"""
|
|
INSERT INTO crm_contract_product (
|
|
id, contract_id, product_id, product_name, product_no, category_id, category_name,
|
|
model, unit, quantity, price, discount, tax_rate, tax_amount, subtotal,
|
|
delivery_date, delivery_status, delivered_quantity, description, package_spec,
|
|
delivery_method, sort, tenant_no, order_num, remark, create_by, create_dept_id,
|
|
create_time, update_by, update_time, del_flag
|
|
) VALUES (
|
|
:id, :contract_id, :product_id, :product_name, :product_no, :category_id, :category_name,
|
|
:model, :unit, :quantity, :price, :discount, :tax_rate, :tax_amount, :subtotal,
|
|
:delivery_date, :delivery_status, :delivered_quantity, :description, :package_spec,
|
|
:delivery_method, :sort, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
|
:create_time, :update_by, :update_time, :del_flag
|
|
)
|
|
"""
|
|
)
|
|
|
|
INSERT_CONTRACT_PRICE_TIER_SQL = text(
|
|
"""
|
|
INSERT INTO crm_contract_product_price_tier (
|
|
id, contract_id, contract_product_id, start_date, end_date, price,
|
|
letter_oss_ids, letter_oss_id, sort, tenant_no, order_num, remark,
|
|
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
|
) VALUES (
|
|
:id, :contract_id, :contract_product_id, :start_date, :end_date, :price,
|
|
:letter_oss_ids, :letter_oss_id, :sort, :tenant_no, :order_num, :remark,
|
|
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
|
)
|
|
"""
|
|
)
|
|
|
|
INSERT_CONTRACT_APPROVAL_SQL = text(
|
|
"""
|
|
INSERT INTO crm_contract_approval (
|
|
id, contract_id, flow_id, step, step_name, approver_id, approver_name, approver_dept,
|
|
approval_result, approval_opinion, approval_time, approval_duration, attachments,
|
|
is_current, sort, tenant_no, order_num, remark, create_by, create_dept_id,
|
|
create_time, update_by, update_time, del_flag
|
|
) VALUES (
|
|
:id, :contract_id, :flow_id, :step, :step_name, :approver_id, :approver_name, :approver_dept,
|
|
:approval_result, :approval_opinion, :approval_time, :approval_duration, :attachments,
|
|
:is_current, :sort, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
|
:create_time, :update_by, :update_time, :del_flag
|
|
)
|
|
"""
|
|
)
|
|
|
|
INSERT_CONTRACT_CHANGE_SQL = text(
|
|
"""
|
|
INSERT INTO crm_contract_change (
|
|
id, contract_id, contract_no, change_type, change_reason, change_content, old_value,
|
|
new_value, change_amount, approval_status, approver_id, approver_name, approval_time,
|
|
approval_opinion, attachment_files, tenant_no, order_num, remark, create_by, create_dept_id,
|
|
create_time, update_by, update_time, del_flag
|
|
) VALUES (
|
|
:id, :contract_id, :contract_no, :change_type, :change_reason, :change_content, :old_value,
|
|
:new_value, :change_amount, :approval_status, :approver_id, :approver_name, :approval_time,
|
|
:approval_opinion, :attachment_files, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
|
:create_time, :update_by, :update_time, :del_flag
|
|
)
|
|
"""
|
|
)
|
|
|
|
STEP_INSERT_SQL = {
|
|
"crm_contract_template": INSERT_CONTRACT_TEMPLATE_SQL,
|
|
"crm_contract": INSERT_CONTRACT_SQL,
|
|
"crm_contract_product": INSERT_CONTRACT_PRODUCT_SQL,
|
|
"crm_contract_product_price_tier": INSERT_CONTRACT_PRICE_TIER_SQL,
|
|
"crm_contract_approval": INSERT_CONTRACT_APPROVAL_SQL,
|
|
"crm_contract_change": INSERT_CONTRACT_CHANGE_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_contract_template": lambda r: transform_contract_template(r, tenant_no, crm_user_map, admin_id, stats),
|
|
"crm_contract": lambda r: transform_contract(r, tenant_no, crm_user_map, admin_id, oss_map, stats),
|
|
"crm_contract_product": lambda r: transform_contract_product(r, tenant_no, crm_user_map, admin_id, stats),
|
|
"crm_contract_product_price_tier": lambda r: transform_contract_price_tier(
|
|
r, tenant_no, crm_user_map, admin_id, stats
|
|
),
|
|
"crm_contract_approval": lambda r: transform_contract_approval(
|
|
r, tenant_no, crm_user_map, admin_id, oss_map, stats
|
|
),
|
|
"crm_contract_change": lambda r: transform_contract_change(
|
|
r, tenant_no, crm_user_map, admin_id, oss_map, 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_contract" and not dry_run:
|
|
write_mapping_csv(source_rows, mapping_dir / "crm_contract_id_mapping.csv")
|
|
|
|
print_report(stats, dry_run)
|
|
return stats
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Migrate CRM contract 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())
|