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>
269 lines
8.9 KiB
Python
269 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
||
"""将门户 crm_product.product_no 统一为 SXWL + 6 位流水,并写出编号映射 CSV。
|
||
|
||
设计:docs/superpowers/specs/2026-07-12-crm-product-no-renumber-design.md
|
||
|
||
用法:
|
||
cd backend
|
||
venv/bin/python scripts/renumber_crm_product_no.py --dry-run --env-file .env.production
|
||
|
||
venv/bin/python scripts/renumber_crm_product_no.py --apply --env-file .env.production \\
|
||
--mapping-out ../docs/data-migration/mappings/crm_product_no_mapping.csv
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import os
|
||
import re
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
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"
|
||
PRODUCT_NO_PREFIX = "SXWL"
|
||
PRODUCT_NO_SEQ_LEN = 6
|
||
NEW_NO_PATTERN = re.compile(rf"^{PRODUCT_NO_PREFIX}[0-9]{{{PRODUCT_NO_SEQ_LEN}}}$")
|
||
TEMP_PREFIX = "__REN_"
|
||
|
||
|
||
@dataclass
|
||
class RenumberStats:
|
||
total: int = 0
|
||
to_update: int = 0
|
||
skipped: int = 0
|
||
updated: int = 0
|
||
warnings: List[str] = field(default_factory=list)
|
||
|
||
|
||
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 product_id_sort_key(product_id: str) -> int:
|
||
try:
|
||
return int(product_id)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
|
||
def format_new_product_no(seq: int) -> str:
|
||
if seq < 1 or seq > 10**PRODUCT_NO_SEQ_LEN - 1:
|
||
raise ValueError(f"序号超出范围: {seq}")
|
||
return f"{PRODUCT_NO_PREFIX}{seq:0{PRODUCT_NO_SEQ_LEN}d}"
|
||
|
||
|
||
def fetch_products(engine: Engine, tenant_no: str) -> List[dict]:
|
||
sql = text(
|
||
"""
|
||
SELECT id, product_no, product_name, category_id
|
||
FROM crm_product
|
||
WHERE tenant_no = :tenant_no AND del_flag = 0
|
||
"""
|
||
)
|
||
with engine.connect() as conn:
|
||
rows = [dict(r._mapping) for r in conn.execute(sql, {"tenant_no": tenant_no})]
|
||
rows.sort(key=lambda r: product_id_sort_key(str(r["id"])))
|
||
return rows
|
||
|
||
|
||
def build_mapping(rows: List[dict], renumbered_at: Optional[str] = None) -> List[dict]:
|
||
mapping: List[dict] = []
|
||
for seq, row in enumerate(rows, start=1):
|
||
pid = str(row["id"])
|
||
old_no = (row.get("product_no") or "").strip()
|
||
new_no = format_new_product_no(seq)
|
||
mapping.append(
|
||
{
|
||
"product_id": pid,
|
||
"old_product_no": old_no,
|
||
"new_product_no": new_no,
|
||
"product_name": (row.get("product_name") or "")[:200],
|
||
"category_id": str(row.get("category_id") or ""),
|
||
"seq": seq,
|
||
"renumbered_at": renumbered_at or "",
|
||
}
|
||
)
|
||
return mapping
|
||
|
||
|
||
def validate_mapping(mapping: List[dict], stats: RenumberStats) -> None:
|
||
new_nos = [m["new_product_no"] for m in mapping]
|
||
if len(new_nos) != len(set(new_nos)):
|
||
stats.warnings.append("新编号存在重复")
|
||
old_nos = [m["old_product_no"] for m in mapping if m["old_product_no"]]
|
||
if len(old_nos) != len(set(old_nos)):
|
||
stats.warnings.append("旧编号存在重复(仍将按 product_id 映射)")
|
||
for m in mapping:
|
||
if not NEW_NO_PATTERN.match(m["new_product_no"]):
|
||
stats.warnings.append(f"非法新编号: {m['new_product_no']} (id={m['product_id']})")
|
||
|
||
|
||
def write_mapping_csv(path: Path, mapping: List[dict], renumbered_at: str) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
for row in mapping:
|
||
if not row.get("renumbered_at"):
|
||
row["renumbered_at"] = renumbered_at
|
||
fieldnames = [
|
||
"product_id",
|
||
"old_product_no",
|
||
"new_product_no",
|
||
"product_name",
|
||
"category_id",
|
||
"seq",
|
||
"renumbered_at",
|
||
]
|
||
with path.open("w", encoding="utf-8", newline="") as f:
|
||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||
writer.writeheader()
|
||
writer.writerows(mapping)
|
||
|
||
|
||
def apply_renumber(engine: Engine, mapping: List[dict], tenant_no: str, stats: RenumberStats) -> None:
|
||
"""两阶段 UPDATE,避免 uk_product_no 中间态冲突。"""
|
||
phase1_sql = text(
|
||
"""
|
||
UPDATE crm_product
|
||
SET product_no = :temp_no, update_time = NOW()
|
||
WHERE id = :id AND tenant_no = :tenant_no AND del_flag = 0
|
||
"""
|
||
)
|
||
phase2_sql = text(
|
||
"""
|
||
UPDATE crm_product
|
||
SET product_no = :new_no, update_time = NOW()
|
||
WHERE id = :id AND tenant_no = :tenant_no AND del_flag = 0
|
||
"""
|
||
)
|
||
|
||
with engine.begin() as conn:
|
||
for m in mapping:
|
||
old_no = m["old_product_no"]
|
||
new_no = m["new_product_no"]
|
||
if old_no == new_no:
|
||
stats.skipped += 1
|
||
continue
|
||
stats.to_update += 1
|
||
temp_no = f"{TEMP_PREFIX}{m['product_id']}"[:50]
|
||
conn.execute(
|
||
phase1_sql,
|
||
{"temp_no": temp_no, "id": m["product_id"], "tenant_no": tenant_no},
|
||
)
|
||
for m in mapping:
|
||
old_no = m["old_product_no"]
|
||
new_no = m["new_product_no"]
|
||
if old_no == new_no:
|
||
continue
|
||
result = conn.execute(
|
||
phase2_sql,
|
||
{"new_no": new_no, "id": m["product_id"], "tenant_no": tenant_no},
|
||
)
|
||
stats.updated += int(result.rowcount or 0)
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="CRM 产品编号统一为 SXWL + 6 位流水")
|
||
parser.add_argument("--dry-run", action="store_true", help="仅预览,不写库")
|
||
parser.add_argument("--apply", action="store_true", help="执行 UPDATE 并写映射")
|
||
parser.add_argument("--env-file", default=None, help="如 .env.production")
|
||
parser.add_argument(
|
||
"--mapping-out",
|
||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/crm_product_no_mapping.csv"),
|
||
help="编号映射 CSV 输出路径",
|
||
)
|
||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||
args = parser.parse_args()
|
||
|
||
if args.apply and args.dry_run:
|
||
print("请只指定 --dry-run 或 --apply 之一")
|
||
return 1
|
||
if not args.apply and not args.dry_run:
|
||
args.dry_run = True
|
||
|
||
load_settings_env(args.env_file)
|
||
from app.core.config import Settings
|
||
|
||
settings = Settings()
|
||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||
|
||
stats = RenumberStats()
|
||
rows = fetch_products(engine, args.tenant_no)
|
||
stats.total = len(rows)
|
||
if stats.total == 0:
|
||
print("目标库无有效产品,退出")
|
||
return 1
|
||
|
||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
mapping = build_mapping(rows, renumbered_at=now_str if args.apply else "")
|
||
validate_mapping(mapping, stats)
|
||
|
||
for m in mapping:
|
||
if m["old_product_no"] != m["new_product_no"]:
|
||
stats.to_update += 1
|
||
else:
|
||
stats.skipped += 1
|
||
|
||
mode = "DRY-RUN" if args.dry_run else "APPLY"
|
||
print(f"租户: {args.tenant_no}")
|
||
print(f"有效产品: {stats.total}")
|
||
print(f"需更新编号: {stats.to_update} 已符合跳过: {stats.skipped}")
|
||
print("\n抽样映射(前 5 / 后 3):")
|
||
for m in mapping[:5]:
|
||
print(f" id={m['product_id']} {m['old_product_no']!r} -> {m['new_product_no']}")
|
||
if len(mapping) > 8:
|
||
print(" ...")
|
||
for m in mapping[-3:]:
|
||
print(f" id={m['product_id']} {m['old_product_no']!r} -> {m['new_product_no']}")
|
||
next_seq = stats.total + 1
|
||
print(f"\n下一自动编号应为: {format_new_product_no(next_seq)}")
|
||
|
||
if stats.warnings:
|
||
print("\n警告:")
|
||
for w in stats.warnings:
|
||
print(f" - {w}")
|
||
|
||
if args.dry_run:
|
||
print("\n=== 产品编号统一 [DRY-RUN] ===")
|
||
print("未写库。确认后加 --apply --mapping-out ...")
|
||
return 0
|
||
|
||
mapping_path = Path(args.mapping_out)
|
||
if not mapping_path.is_absolute():
|
||
mapping_path = BACKEND_ROOT / mapping_path
|
||
|
||
apply_stats = RenumberStats(total=stats.total)
|
||
apply_renumber(engine, mapping, args.tenant_no, apply_stats)
|
||
|
||
write_mapping_csv(mapping_path, mapping, now_str)
|
||
|
||
print(f"\n映射已写入: {mapping_path}")
|
||
print("\n=== 产品编号统一 [COMMITTED] ===")
|
||
print(f"更新行数: {apply_stats.updated}")
|
||
print(f"跳过(已符合): {apply_stats.skipped}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|