Files
shanxia2.0/backend/scripts/renumber_crm_product_no.py
T
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

269 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())