This commit is contained in:
@@ -142,6 +142,16 @@ class AgentMapping:
|
||||
no_downtime: bool = False # 迁移时免停机:有生效套餐的卡直接以 network_status=1 导入
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageLifecycleOverride:
|
||||
"""单卡旧套餐生命周期的人工裁决。"""
|
||||
|
||||
iccid: str
|
||||
active_life_id: str
|
||||
pending_life_ids: tuple[str, ...] = ()
|
||||
skipped_life_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Mapping:
|
||||
"""整体映射(对应单个 mapping.yaml)。"""
|
||||
@@ -155,6 +165,7 @@ class Mapping:
|
||||
packages: dict[str, PackageMapping] = field(default_factory=dict)
|
||||
series: dict[str, SeriesMapping] = field(default_factory=dict)
|
||||
agents: dict[str, AgentMapping] = field(default_factory=dict)
|
||||
package_lifecycle_overrides: dict[str, PackageLifecycleOverride] = field(default_factory=dict)
|
||||
|
||||
def lookup_carrier(self, legacy_account_id: str) -> Optional[CarrierMapping]:
|
||||
return self.carriers.get(str(legacy_account_id or "").strip())
|
||||
@@ -167,6 +178,9 @@ class Mapping:
|
||||
return None
|
||||
return self.series.get(str(legacy_series_id).strip())
|
||||
|
||||
def lookup_package_lifecycle_override(self, iccid: str) -> Optional[PackageLifecycleOverride]:
|
||||
return self.package_lifecycle_overrides.get(str(iccid or "").strip())
|
||||
|
||||
def is_no_downtime_agent(self, legacy_agent_id: str) -> bool:
|
||||
"""判断代理是否配置了免停机迁移。"""
|
||||
agent = self.agents.get(str(legacy_agent_id or "").strip())
|
||||
@@ -261,6 +275,8 @@ def load_mapping(config_dir: Path) -> Mapping:
|
||||
)
|
||||
m.series[s.legacy_series_id] = s
|
||||
|
||||
m.package_lifecycle_overrides = _load_package_lifecycle_overrides(raw.get("package_lifecycle_overrides") or [])
|
||||
|
||||
for item in raw.get("agents") or []:
|
||||
a = AgentMapping(
|
||||
legacy_agent_id=str(item["legacy_agent_id"]).strip(),
|
||||
@@ -323,6 +339,39 @@ def _load_package_rules(raw: dict) -> PackageRules:
|
||||
return PackageRules(migrate_statuses=statuses)
|
||||
|
||||
|
||||
def _load_package_lifecycle_overrides(raw: Any) -> dict[str, PackageLifecycleOverride]:
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("mapping.yaml.package_lifecycle_overrides 必须是列表")
|
||||
overrides: dict[str, PackageLifecycleOverride] = {}
|
||||
for idx, item in enumerate(raw, start=1):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"mapping.yaml.package_lifecycle_overrides[{idx}] 必须是 mapping 类型")
|
||||
iccid = _opt_str(item.get("iccid"))
|
||||
active = _opt_str(item.get("active_life_id"))
|
||||
if not iccid or len(iccid) != 20 or not iccid.isdigit():
|
||||
raise ValueError(f"mapping.yaml.package_lifecycle_overrides[{idx}].iccid 必须是 20 位数字")
|
||||
if not active:
|
||||
raise ValueError(f"mapping.yaml.package_lifecycle_overrides[{idx}].active_life_id 不能为空")
|
||||
pending = _life_ids(item.get("pending_life_ids") or [], f"package_lifecycle_overrides[{idx}].pending_life_ids")
|
||||
skipped = _life_ids(item.get("skipped_life_ids") or [], f"package_lifecycle_overrides[{idx}].skipped_life_ids")
|
||||
all_ids = (active,) + pending + skipped
|
||||
if len(all_ids) != len(set(all_ids)):
|
||||
raise ValueError(f"mapping.yaml.package_lifecycle_overrides[{idx}] 生命周期 ID 不可重复或跨状态重复")
|
||||
if iccid in overrides:
|
||||
raise ValueError(f"mapping.yaml.package_lifecycle_overrides iccid 重复: {iccid}")
|
||||
overrides[iccid] = PackageLifecycleOverride(iccid, active, pending, skipped)
|
||||
return overrides
|
||||
|
||||
|
||||
def _life_ids(raw: Any, field_name: str) -> tuple[str, ...]:
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(f"mapping.yaml.{field_name} 必须是列表")
|
||||
values = tuple(_opt_str(value) or "" for value in raw)
|
||||
if not all(values):
|
||||
raise ValueError(f"mapping.yaml.{field_name} 不可包含空生命周期 ID")
|
||||
return values
|
||||
|
||||
|
||||
def _load_overrides(raw: dict) -> Overrides:
|
||||
"""加载资产覆盖项。"""
|
||||
if not isinstance(raw, dict):
|
||||
@@ -420,6 +469,23 @@ def _render_commented_mapping_yaml(mapping: Mapping) -> str:
|
||||
for status in mapping.package_rules.migrate_statuses:
|
||||
lines.append(f" - {_yaml_scalar(status)}")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"# ============== 套餐生命周期特殊裁决 ==============",
|
||||
"# life_id 来自 tbl_card_life.id;每项必须明确裁决该卡所有可迁移正式套餐。",
|
||||
])
|
||||
if not mapping.package_lifecycle_overrides:
|
||||
lines.append("package_lifecycle_overrides: []")
|
||||
else:
|
||||
lines.append("package_lifecycle_overrides:")
|
||||
for item in sorted(mapping.package_lifecycle_overrides.values(), key=lambda x: x.iccid):
|
||||
lines.extend([
|
||||
f" - iccid: {_yaml_scalar(item.iccid)}",
|
||||
f" active_life_id: {_yaml_scalar(item.active_life_id)}",
|
||||
f" pending_life_ids: {json.dumps(list(item.pending_life_ids), ensure_ascii=False)}",
|
||||
f" skipped_life_ids: {json.dumps(list(item.skipped_life_ids), ensure_ascii=False)}",
|
||||
])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"# ============== 少量资产级覆盖 ==============",
|
||||
|
||||
333
scripts/migration/lib/shop_import.py
Normal file
333
scripts/migration/lib/shop_import.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""店铺批量导入的本地预检与审核产物。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .csv_loader import ErrorRow
|
||||
|
||||
CSV_HEADERS = (
|
||||
"奇成代理名称", "店铺名(新卡管)", "用户名", "联系方式", "业务员", "是否有上级代理", "上级代理名称",
|
||||
)
|
||||
PHONE_RE = re.compile(r"^[0-9]{11}$")
|
||||
SHOP_CODE_PREFIX_RE = re.compile(r"^[A-Za-z0-9_-]{1,45}$")
|
||||
BATCH_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
PASSWORD_RULE = "adm@{phone_last4}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShopImportConfig:
|
||||
"""店铺导入的本地决策配置。"""
|
||||
|
||||
batch_id: str
|
||||
shop_code_prefix: str
|
||||
default_role_id: int
|
||||
operator_id: int
|
||||
initial_password_rule: str
|
||||
salesperson_account_ids: dict[str, int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShopImportRow:
|
||||
"""通过本地字段校验的 CSV 店铺记录。"""
|
||||
|
||||
line_no: int
|
||||
agent_name: str
|
||||
shop_name: str
|
||||
username: str
|
||||
phone: str
|
||||
salesperson: str
|
||||
parent_agent_name: str
|
||||
shop_code: str
|
||||
parent_line_no: int = 0
|
||||
parent_shop_code: str = ""
|
||||
level: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShopImportResult:
|
||||
"""单条计划导入的非敏感审核投影。"""
|
||||
|
||||
line_no: int
|
||||
shop_code: str
|
||||
shop_name: str
|
||||
level: int
|
||||
parent_line_no: int
|
||||
parent_shop_code: str
|
||||
salesperson: str
|
||||
salesperson_account_id: int
|
||||
status: str = "planned"
|
||||
|
||||
def to_csv_row(self) -> list[str]:
|
||||
return [
|
||||
str(self.line_no), self.shop_code, self.shop_name, str(self.level),
|
||||
str(self.parent_line_no) if self.parent_line_no else "", self.parent_shop_code,
|
||||
self.salesperson, str(self.salesperson_account_id), self.status,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShopImportPlan:
|
||||
"""本地预检后的稳定导入计划。"""
|
||||
|
||||
config: ShopImportConfig
|
||||
input_count: int = 0
|
||||
rows: list[ShopImportRow] = field(default_factory=list)
|
||||
errors: list[ErrorRow] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def results(self) -> list[ShopImportResult]:
|
||||
return [
|
||||
ShopImportResult(
|
||||
line_no=row.line_no, shop_code=row.shop_code, shop_name=row.shop_name,
|
||||
level=row.level, parent_line_no=row.parent_line_no, parent_shop_code=row.parent_shop_code,
|
||||
salesperson=row.salesperson,
|
||||
salesperson_account_id=self.config.salesperson_account_ids[row.salesperson],
|
||||
)
|
||||
for row in self.rows
|
||||
]
|
||||
|
||||
|
||||
def load_config(path: Path) -> tuple[ShopImportConfig | None, list[ErrorRow]]:
|
||||
"""加载并校验店铺导入本地 YAML 配置。"""
|
||||
errors: list[ErrorRow] = []
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
except FileNotFoundError:
|
||||
return None, [_config_error("file", str(path), "config_missing", "导入配置文件不存在")]
|
||||
except yaml.YAMLError as exc:
|
||||
return None, [_config_error("file", str(path), "config_yaml", f"配置 YAML 无法解析: {exc}")]
|
||||
if not isinstance(raw, dict):
|
||||
return None, [_config_error("root", "", "config_type", "导入配置顶层必须是 mapping")]
|
||||
|
||||
batch_id = _as_text(raw.get("batch_id"))
|
||||
prefix = _as_text(raw.get("shop_code_prefix"))
|
||||
default_role_id = _as_positive_int(raw.get("default_role_id"), "default_role_id", errors)
|
||||
operator_id = _as_positive_int(raw.get("operator_id"), "operator_id", errors)
|
||||
password_rule = _as_text(raw.get("initial_password_rule"))
|
||||
mappings = _load_salesperson_mapping(raw.get("salesperson_account_ids"), errors)
|
||||
|
||||
if not BATCH_ID_RE.fullmatch(batch_id):
|
||||
errors.append(_config_error("batch_id", batch_id, "invalid_batch_id", "batch_id 只能包含字母、数字、下划线和连字符,长度 1-64"))
|
||||
if not SHOP_CODE_PREFIX_RE.fullmatch(prefix):
|
||||
errors.append(_config_error("shop_code_prefix", prefix, "invalid_shop_code_prefix", "店铺编号前缀只能包含字母、数字、下划线和连字符,长度 1-45"))
|
||||
if password_rule != PASSWORD_RULE:
|
||||
errors.append(_config_error("initial_password_rule", password_rule, "invalid_password_rule", f"初始密码规则必须是 {PASSWORD_RULE}"))
|
||||
if errors:
|
||||
return None, errors
|
||||
return ShopImportConfig(batch_id, prefix, default_role_id, operator_id, password_rule, mappings), []
|
||||
|
||||
|
||||
def load_plan(csv_path: Path, config: ShopImportConfig) -> ShopImportPlan:
|
||||
"""加载 CSV、执行本地预检并返回父级优先的导入计划。"""
|
||||
plan = ShopImportPlan(config=config)
|
||||
try:
|
||||
with csv_path.open("r", encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
headers = [_clean(header) for header in (reader.fieldnames or [])]
|
||||
missing = [header for header in CSV_HEADERS if header not in headers]
|
||||
if missing:
|
||||
plan.errors.append(_csv_error(1, "header", ",".join(missing), "missing_headers", f"缺少必备列: {', '.join(missing)}"))
|
||||
return plan
|
||||
plan.rows = _load_rows(reader, plan)
|
||||
except FileNotFoundError:
|
||||
plan.errors.append(_csv_error(0, "file", str(csv_path), "csv_missing", "店铺导入 CSV 不存在"))
|
||||
return plan
|
||||
|
||||
if plan.errors:
|
||||
return plan
|
||||
if not plan.rows:
|
||||
plan.errors.append(_csv_error(1, "rows", "", "empty_csv", "店铺导入 CSV 至少需要一条数据"))
|
||||
return plan
|
||||
_resolve_parents(plan)
|
||||
if plan.errors:
|
||||
return plan
|
||||
plan.rows = _parent_first(plan.rows, plan)
|
||||
return plan
|
||||
|
||||
|
||||
def initial_password_hash(phone: str) -> str:
|
||||
"""仅在内存中构造初始密码并返回 bcrypt 哈希。"""
|
||||
import bcrypt
|
||||
|
||||
return bcrypt.hashpw(f"adm@{phone[-4:]}".encode(), bcrypt.gensalt(rounds=10)).decode()
|
||||
|
||||
|
||||
def write_errors(path: Path, errors: list[ErrorRow]) -> None:
|
||||
"""写入不含明文密码的错误清单。"""
|
||||
_write_csv(path, ["source_file", "line_no", "field", "value", "error_code", "message"], [error.to_csv_row() for error in errors])
|
||||
|
||||
|
||||
def write_results(path: Path, results: list[ShopImportResult]) -> None:
|
||||
"""写入不含密码字段的计划结果清单。"""
|
||||
_write_csv(
|
||||
path,
|
||||
["csv_line_no", "shop_code", "shop_name", "level", "parent_csv_line_no", "parent_shop_code", "salesperson", "salesperson_account_id", "status"],
|
||||
[result.to_csv_row() for result in results],
|
||||
)
|
||||
|
||||
|
||||
def write_summary(path: Path, plan: ShopImportPlan, sql_path: Path) -> None:
|
||||
"""写入本次生成摘要。"""
|
||||
parent_count = sum(1 for row in plan.rows if row.parent_line_no)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
"\n".join((
|
||||
f"batch_id={plan.config.batch_id}", "mode=generate_sql", f"input_count={plan.input_count}",
|
||||
f"planned_count={len(plan.rows)}", f"parent_relation_count={parent_count}",
|
||||
f"error_count={len(plan.errors)}", f"sql_path={sql_path}",
|
||||
)) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _load_rows(reader: csv.DictReader, plan: ShopImportPlan) -> list[ShopImportRow]:
|
||||
rows: list[ShopImportRow] = []
|
||||
seen: dict[str, dict[str, int]] = {"shop_name": {}, "username": {}, "phone": {}}
|
||||
for line_no, raw in enumerate(reader, start=2):
|
||||
plan.input_count += 1
|
||||
values = {_clean(key): _clean(value) for key, value in raw.items() if key is not None}
|
||||
agent_name = values["奇成代理名称"]
|
||||
shop_name = values["店铺名(新卡管)"]
|
||||
username = values["用户名"]
|
||||
phone = values["联系方式"]
|
||||
salesperson = values["业务员"]
|
||||
parent_flag = values["是否有上级代理"]
|
||||
parent_agent_name = values["上级代理名称"]
|
||||
row_errors: list[ErrorRow] = []
|
||||
for field, value, limit in (("奇成代理名称", agent_name, 50), ("店铺名(新卡管)", shop_name, 100), ("用户名", username, 50), ("业务员", salesperson, 100)):
|
||||
if not value:
|
||||
row_errors.append(_csv_error(line_no, field, value, "required", f"{field} 不能为空"))
|
||||
elif len(value) > limit:
|
||||
row_errors.append(_csv_error(line_no, field, value, "too_long", f"{field} 长度不能超过 {limit}"))
|
||||
if not PHONE_RE.fullmatch(phone):
|
||||
row_errors.append(_csv_error(line_no, "联系方式", phone, "invalid_phone", "联系方式必须是 11 位 ASCII 数字"))
|
||||
if username and len(username) > 50:
|
||||
row_errors.append(_csv_error(line_no, "用户名", username, "invalid_username", "用户名长度不能超过 50"))
|
||||
if salesperson not in plan.config.salesperson_account_ids:
|
||||
row_errors.append(_csv_error(line_no, "业务员", salesperson, "salesperson_unmapped", "业务员未配置平台账号映射"))
|
||||
if parent_flag not in ("", "有", "无", "否"):
|
||||
row_errors.append(_csv_error(line_no, "是否有上级代理", parent_flag, "invalid_parent_flag", "是否有上级代理只能填写 有、无、否或留空"))
|
||||
has_parent = parent_flag == "有"
|
||||
if has_parent and not parent_agent_name:
|
||||
row_errors.append(_csv_error(line_no, "上级代理名称", "", "parent_missing", "标记有上级代理时必须填写上级代理名称"))
|
||||
if not has_parent and parent_agent_name:
|
||||
row_errors.append(_csv_error(line_no, "上级代理名称", parent_agent_name, "parent_flag_mismatch", "填写上级代理名称时是否有上级代理必须为 有"))
|
||||
for field, value in (("shop_name", shop_name), ("username", username), ("phone", phone)):
|
||||
if not value:
|
||||
continue
|
||||
if value in seen[field]:
|
||||
row_errors.append(_csv_error(line_no, field, value, "duplicate", f"与第 {seen[field][value]} 行重复"))
|
||||
else:
|
||||
seen[field][value] = line_no
|
||||
if row_errors:
|
||||
plan.errors.extend(row_errors)
|
||||
continue
|
||||
rows.append(ShopImportRow(
|
||||
line_no=line_no, agent_name=agent_name, shop_name=shop_name, username=username, phone=phone,
|
||||
salesperson=salesperson, parent_agent_name=parent_agent_name,
|
||||
shop_code=f"{plan.config.shop_code_prefix}-{line_no - 1:04d}",
|
||||
))
|
||||
return rows
|
||||
|
||||
|
||||
def _resolve_parents(plan: ShopImportPlan) -> None:
|
||||
by_agent: dict[str, list[ShopImportRow]] = {}
|
||||
for row in plan.rows:
|
||||
by_agent.setdefault(row.agent_name, []).append(row)
|
||||
for row in plan.rows:
|
||||
if not row.parent_agent_name:
|
||||
continue
|
||||
parents = by_agent.get(row.parent_agent_name, [])
|
||||
if len(parents) != 1:
|
||||
reason = "不存在" if not parents else f"匹配到 {len(parents)} 条记录"
|
||||
plan.errors.append(_csv_error(row.line_no, "上级代理名称", row.parent_agent_name, "parent_unresolved", f"上级代理 {reason},无法唯一解析"))
|
||||
continue
|
||||
parent = parents[0]
|
||||
row.parent_line_no = parent.line_no
|
||||
row.parent_shop_code = parent.shop_code
|
||||
|
||||
|
||||
def _parent_first(rows: list[ShopImportRow], plan: ShopImportPlan) -> list[ShopImportRow]:
|
||||
by_line = {row.line_no: row for row in rows}
|
||||
ordered = [row for row in rows if not row.parent_line_no]
|
||||
state = {row.line_no: 2 for row in ordered}
|
||||
|
||||
def visit(row: ShopImportRow) -> None:
|
||||
marker = state.get(row.line_no, 0)
|
||||
if marker == 1:
|
||||
plan.errors.append(_csv_error(row.line_no, "上级代理名称", row.parent_agent_name, "parent_cycle", "上级代理引用形成循环"))
|
||||
return
|
||||
if marker == 2:
|
||||
return
|
||||
state[row.line_no] = 1
|
||||
parent = by_line[row.parent_line_no]
|
||||
visit(parent)
|
||||
row.level = parent.level + 1
|
||||
if row.level > 7:
|
||||
plan.errors.append(_csv_error(row.line_no, "上级代理名称", row.parent_agent_name, "level_exceeded", "店铺层级不能超过 7 级"))
|
||||
state[row.line_no] = 2
|
||||
ordered.append(row)
|
||||
|
||||
for row in rows:
|
||||
if row.parent_line_no:
|
||||
visit(row)
|
||||
return ordered
|
||||
|
||||
|
||||
def _load_salesperson_mapping(raw: Any, errors: list[ErrorRow]) -> dict[str, int]:
|
||||
if not isinstance(raw, dict):
|
||||
errors.append(_config_error("salesperson_account_ids", "", "mapping_type", "业务员账号映射必须是 mapping"))
|
||||
return {}
|
||||
mappings: dict[str, int] = {}
|
||||
for name, account_id in raw.items():
|
||||
clean_name = _as_text(name)
|
||||
try:
|
||||
clean_id = int(account_id)
|
||||
except (TypeError, ValueError):
|
||||
clean_id = 0
|
||||
if not clean_name or clean_id <= 0:
|
||||
errors.append(_config_error("salesperson_account_ids", f"{clean_name}:{account_id}", "invalid_mapping", "业务员名称和平台账号 ID 必须有效"))
|
||||
continue
|
||||
mappings[clean_name] = clean_id
|
||||
return mappings
|
||||
|
||||
|
||||
def _as_positive_int(raw: Any, field: str, errors: list[ErrorRow]) -> int:
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
value = 0
|
||||
if value <= 0:
|
||||
errors.append(_config_error(field, _as_text(raw), "invalid_id", f"{field} 必须是正整数"))
|
||||
return value
|
||||
|
||||
|
||||
def _as_text(raw: Any) -> str:
|
||||
return _clean(str(raw)) if raw is not None else ""
|
||||
|
||||
|
||||
def _clean(raw: str | None) -> str:
|
||||
return (raw or "").replace("\ufeff", "").replace("\u00a0", "").strip()
|
||||
|
||||
|
||||
def _config_error(field: str, value: str, code: str, message: str) -> ErrorRow:
|
||||
return ErrorRow("shop_bulk_import.yaml", 0, field, value, code, message)
|
||||
|
||||
|
||||
def _csv_error(line_no: int, field: str, value: str, code: str, message: str) -> ErrorRow:
|
||||
return ErrorRow("shop_bulk_import.csv", line_no, field, value, code, message)
|
||||
|
||||
|
||||
def _write_csv(path: Path, headers: list[str], rows: list[list[str]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(headers)
|
||||
writer.writerows(rows)
|
||||
185
scripts/migration/lib/shop_sql.py
Normal file
185
scripts/migration/lib/shop_sql.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""店铺批量导入 SQL 构造。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
|
||||
from .shop_import import ShopImportPlan, ShopImportRow, initial_password_hash
|
||||
|
||||
|
||||
def build_shop_sql(plan: ShopImportPlan) -> str:
|
||||
"""生成包含店铺初始事实的单事务 SQL。"""
|
||||
lines = [
|
||||
"-- 店铺批量导入 SQL;执行前必须审核结果清单与摘要。",
|
||||
f"-- 批次: {plan.config.batch_id}",
|
||||
f"-- 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
"-- 建议执行: psql -1 -v ON_ERROR_STOP=1 -f <此文件>",
|
||||
"",
|
||||
"-- psql -1 开启的事务内先执行以下守卫。",
|
||||
"",
|
||||
*_preflight_sql(plan),
|
||||
]
|
||||
for row in plan.rows:
|
||||
lines.extend(_row_sql(row, plan))
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _preflight_sql(plan: ShopImportPlan) -> list[str]:
|
||||
config = plan.config
|
||||
mapped_salespeople = []
|
||||
seen_salespeople: set[str] = set()
|
||||
for row in plan.rows:
|
||||
if row.salesperson not in seen_salespeople:
|
||||
seen_salespeople.add(row.salesperson)
|
||||
mapped_salespeople.append((row.line_no, row.salesperson, config.salesperson_account_ids[row.salesperson]))
|
||||
target_rows = ",\n ".join(
|
||||
f"({row.line_no}, {_sql_str(row.shop_code)}, {_sql_str(row.username)}, {_sql_str(row.phone)})"
|
||||
for row in plan.rows
|
||||
)
|
||||
salesperson_rows = ",\n ".join(
|
||||
f"({line_no}, {_sql_str(name)}, {account_id})" for line_no, name, account_id in mapped_salespeople
|
||||
)
|
||||
return [
|
||||
"-- 所有目标库守卫必须在业务写入前完成。",
|
||||
"DO $shop_import_guard$",
|
||||
"DECLARE",
|
||||
" v_row record;",
|
||||
"BEGIN",
|
||||
" IF NOT EXISTS (SELECT 1 FROM tb_role WHERE id = " + str(config.default_role_id) + " AND role_type = 2 AND status = 1 AND deleted_at IS NULL) THEN",
|
||||
f" RAISE EXCEPTION '导入配置错误: default_role_id={config.default_role_id} 不是启用的客户角色';",
|
||||
" END IF;",
|
||||
" IF NOT EXISTS (SELECT 1 FROM tb_account WHERE id = " + str(config.operator_id) + " AND user_type = 1 AND status = 1 AND deleted_at IS NULL) THEN",
|
||||
f" RAISE EXCEPTION '导入配置错误: operator_id={config.operator_id} 不是启用的超级管理员';",
|
||||
" END IF;",
|
||||
" FOR v_row IN",
|
||||
" VALUES",
|
||||
f" {salesperson_rows}",
|
||||
" LOOP",
|
||||
" IF NOT EXISTS (SELECT 1 FROM tb_account WHERE id = v_row.column3 AND user_type = 2 AND status = 1 AND deleted_at IS NULL) THEN",
|
||||
" RAISE EXCEPTION 'CSV 第 % 行业务员映射无效: % (账号 ID=%)', v_row.column1, v_row.column2, v_row.column3;",
|
||||
" END IF;",
|
||||
" END LOOP;",
|
||||
" FOR v_row IN",
|
||||
" VALUES",
|
||||
f" {target_rows}",
|
||||
" LOOP",
|
||||
" IF EXISTS (SELECT 1 FROM tb_shop WHERE shop_code = v_row.column2 AND deleted_at IS NULL) THEN",
|
||||
" RAISE EXCEPTION 'CSV 第 % 行店铺编号已存在: %', v_row.column1, v_row.column2;",
|
||||
" END IF;",
|
||||
" IF EXISTS (SELECT 1 FROM tb_account WHERE username = v_row.column3 AND deleted_at IS NULL) THEN",
|
||||
" RAISE EXCEPTION 'CSV 第 % 行用户名已存在: %', v_row.column1, v_row.column3;",
|
||||
" END IF;",
|
||||
" IF EXISTS (SELECT 1 FROM tb_account WHERE phone = v_row.column4 AND deleted_at IS NULL) THEN",
|
||||
" RAISE EXCEPTION 'CSV 第 % 行手机号已存在: %', v_row.column1, v_row.column4;",
|
||||
" END IF;",
|
||||
" END LOOP;",
|
||||
"END;",
|
||||
"$shop_import_guard$;",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def _row_sql(row: ShopImportRow, plan: ShopImportPlan) -> list[str]:
|
||||
config = plan.config
|
||||
parent_expr = "NULL" if not row.parent_shop_code else f"(SELECT id FROM tb_shop WHERE shop_code = {_sql_str(row.parent_shop_code)} AND deleted_at IS NULL)"
|
||||
password_hash = initial_password_hash(row.phone)
|
||||
return [
|
||||
f"-- CSV 第 {row.line_no} 行: {_sql_str(row.shop_name)}",
|
||||
"DO $shop_import$",
|
||||
"DECLARE",
|
||||
" v_shop_id bigint;",
|
||||
" v_account_id bigint;",
|
||||
" v_event_id bigint;",
|
||||
" v_parent_id bigint;",
|
||||
" v_operator_name varchar;",
|
||||
"BEGIN",
|
||||
f" SELECT username INTO v_operator_name FROM tb_account WHERE id = {config.operator_id};",
|
||||
" INSERT INTO tb_shop (creator, updater, shop_name, shop_code, parent_id, business_owner_account_id, level, contact_name, contact_phone, province, city, district, address, status, client_login_disabled, created_at, updated_at)",
|
||||
" VALUES ("
|
||||
f"{config.operator_id}, {config.operator_id}, {_sql_str(row.shop_name)}, {_sql_str(row.shop_code)}, {parent_expr}, "
|
||||
f"{config.salesperson_account_ids[row.salesperson]}, {row.level}, {_sql_str(row.agent_name)}, {_sql_str(row.phone)}, '', '', '', '', 1, FALSE, NOW(), NOW())",
|
||||
" RETURNING id, parent_id INTO v_shop_id, v_parent_id;",
|
||||
"",
|
||||
" INSERT INTO tb_account (creator, updater, username, phone, password, user_type, shop_id, is_primary, status, created_at, updated_at)",
|
||||
" VALUES ("
|
||||
f"{config.operator_id}, {config.operator_id}, {_sql_str(row.username)}, {_sql_str(row.phone)}, {_sql_str(password_hash)}, 3, v_shop_id, TRUE, 1, NOW(), NOW())",
|
||||
" RETURNING id INTO v_account_id;",
|
||||
"",
|
||||
" INSERT INTO tb_account_role (account_id, role_id, status, creator, updater, created_at, updated_at)",
|
||||
f" VALUES (v_account_id, {config.default_role_id}, 1, {config.operator_id}, {config.operator_id}, NOW(), NOW());",
|
||||
"",
|
||||
" INSERT INTO tb_shop_role (shop_id, role_id, status, creator, updater, created_at, updated_at)",
|
||||
f" VALUES (v_shop_id, {config.default_role_id}, 1, {config.operator_id}, {config.operator_id}, NOW(), NOW());",
|
||||
"",
|
||||
" INSERT INTO tb_agent_wallet (shop_id, wallet_type, balance, frozen_balance, credit_enabled, credit_limit, currency, status, version, shop_id_tag)",
|
||||
" SELECT v_shop_id, 'main', 0, 0, default_credit_enabled, default_credit_limit, 'CNY', 1, 0, v_shop_id",
|
||||
f" FROM tb_role WHERE id = {config.default_role_id} AND deleted_at IS NULL;",
|
||||
"",
|
||||
" INSERT INTO tb_agent_wallet (shop_id, wallet_type, balance, frozen_balance, credit_enabled, credit_limit, currency, status, version, shop_id_tag)",
|
||||
" VALUES (v_shop_id, 'commission', 0, 0, FALSE, 0, 'CNY', 1, 0, v_shop_id);",
|
||||
*_audit_sql(row, plan),
|
||||
"END;",
|
||||
"$shop_import$;",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def _audit_sql(row: ShopImportRow, plan: ShopImportPlan) -> list[str]:
|
||||
config = plan.config
|
||||
owner_id = config.salesperson_account_ids[row.salesperson]
|
||||
created_event_id, created_hash = _audit_ids(config.batch_id, row.line_no, "shop.create")
|
||||
owner_event_id, owner_hash = _audit_ids(config.batch_id, row.line_no, "shop.update_business_owner")
|
||||
identity = (
|
||||
"jsonb_build_object('id', v_shop_id, 'shop_code', " + _sql_str(row.shop_code) + ", 'shop_name', " + _sql_str(row.shop_name) + ", "
|
||||
"'parent_id', v_parent_id, 'level', " + str(row.level) + ")"
|
||||
)
|
||||
after = (
|
||||
"jsonb_build_object('shop_name', " + _sql_str(row.shop_name) + ", 'contact_name', " + _sql_str(row.agent_name) + ", "
|
||||
"'contact_phone', " + _sql_str(row.phone) + ", 'province', '', 'city', '', 'district', '', 'address', '', "
|
||||
"'shop_code', " + _sql_str(row.shop_code) + ", 'parent_id', v_parent_id, 'level', " + str(row.level) + ")"
|
||||
)
|
||||
parent_resource = [
|
||||
" IF v_parent_id IS NOT NULL THEN",
|
||||
" INSERT INTO tb_audit_event_resource (audit_event_id, resource_type, resource_id, resource_key, display_name, relation, role, identity_snapshot, before_data, after_data, subject_visibility, subject_summary, subject_data, sort_order)",
|
||||
" SELECT v_event_id, 'shop', id::text, id::text, shop_name, 'reference', 'shop_parent',",
|
||||
" jsonb_build_object('id', id, 'shop_code', shop_code, 'shop_name', shop_name, 'parent_id', parent_id, 'level', level),",
|
||||
" '{}'::jsonb, '{}'::jsonb, 'internal_only', '', '{}'::jsonb, 0",
|
||||
" FROM tb_shop WHERE id = v_parent_id;",
|
||||
" END IF;",
|
||||
]
|
||||
return [
|
||||
"",
|
||||
" INSERT INTO tb_audit_event (event_id, occurred_at, category, action_code, action_name, summary, actor_kind, actor_id, actor_name, source, scope_type, result, risk_level, metadata, content_hash)",
|
||||
" VALUES ("
|
||||
f"{_sql_str(created_event_id)}, NOW(), 'business', 'shop.create', '创建店铺', '创建店铺', 'account', '{config.operator_id}', v_operator_name, "
|
||||
f"'admin_api', 'platform', 'success', 'normal', jsonb_build_object('batch_id', {_sql_str(config.batch_id)}, 'csv_line_no', {row.line_no}), {_sql_str(created_hash)})",
|
||||
" RETURNING id INTO v_event_id;",
|
||||
" INSERT INTO tb_audit_event_resource (audit_event_id, resource_type, resource_id, resource_key, display_name, relation, role, identity_snapshot, before_data, after_data, subject_visibility, subject_summary, subject_data, sort_order)",
|
||||
" VALUES (v_event_id, 'shop', v_shop_id::text, v_shop_id::text, " + _sql_str(row.shop_name) + ", 'primary', 'shop_target', " + identity + ", '{}'::jsonb, " + after + ", 'internal_only', '', '{}'::jsonb, 0);",
|
||||
*parent_resource,
|
||||
"",
|
||||
" INSERT INTO tb_audit_event (event_id, occurred_at, category, action_code, action_name, summary, actor_kind, actor_id, actor_name, source, scope_type, result, risk_level, metadata, content_hash)",
|
||||
" VALUES ("
|
||||
f"{_sql_str(owner_event_id)}, NOW(), 'business', 'shop.update_business_owner', '更新店铺业务员归属', '设置店铺业务员归属', 'account', '{config.operator_id}', v_operator_name, "
|
||||
f"'admin_api', 'platform', 'success', 'normal', jsonb_build_object('batch_id', {_sql_str(config.batch_id)}, 'csv_line_no', {row.line_no}), {_sql_str(owner_hash)})",
|
||||
" RETURNING id INTO v_event_id;",
|
||||
" INSERT INTO tb_audit_event_resource (audit_event_id, resource_type, resource_id, resource_key, display_name, relation, role, identity_snapshot, before_data, after_data, subject_visibility, subject_summary, subject_data, sort_order)",
|
||||
" VALUES (v_event_id, 'shop', v_shop_id::text, v_shop_id::text, " + _sql_str(row.shop_name) + ", 'primary', 'shop_target', " + identity + ", '{}'::jsonb, " + f"jsonb_build_object('business_owner_account_id', {owner_id})" + ", 'subject_result', '店铺业务员归属已设置', '{}'::jsonb, 0);",
|
||||
*parent_resource,
|
||||
" INSERT INTO tb_audit_event_resource (audit_event_id, resource_type, resource_id, resource_key, display_name, relation, role, identity_snapshot, before_data, after_data, subject_visibility, subject_summary, subject_data, sort_order)",
|
||||
" SELECT v_event_id, 'account', id::text, id::text, username, 'reference', 'shop_business_owner',",
|
||||
" jsonb_build_object('id', id, 'username', username, 'phone', phone, 'user_type', user_type, 'shop_id', shop_id, 'enterprise_id', enterprise_id, 'wecom_userid', wecom_userid, 'wecom_name', wecom_name),",
|
||||
" jsonb_build_object('assigned', FALSE), jsonb_build_object('assigned', TRUE), 'internal_only', '', '{}'::jsonb, 1",
|
||||
f" FROM tb_account WHERE id = {owner_id};",
|
||||
]
|
||||
|
||||
|
||||
def _audit_ids(batch_id: str, line_no: int, action: str) -> tuple[str, str]:
|
||||
seed = f"{batch_id}:{line_no}:{action}"
|
||||
digest = sha256(seed.encode()).hexdigest()
|
||||
return f"evt_{digest[:32]}", digest
|
||||
|
||||
|
||||
def _sql_str(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
@@ -25,7 +25,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -1158,6 +1158,44 @@ def write_step2(
|
||||
return [w[2] for w in warnings]
|
||||
|
||||
|
||||
def _apply_package_lifecycle_override(
|
||||
iccid: str,
|
||||
lifecycles: list[LegacyPackageLifecycle],
|
||||
mapping: Mapping,
|
||||
) -> tuple[list[LegacyPackageLifecycle], str]:
|
||||
"""应用经确认的逐卡裁决;未覆盖资产保持原始分类。"""
|
||||
override = mapping.lookup_package_lifecycle_override(iccid)
|
||||
if override is None:
|
||||
return lifecycles, ""
|
||||
|
||||
records = {item.life_id: item for item in lifecycles if item.source_table == "tbl_card_life"}
|
||||
selected = {override.active_life_id, *override.pending_life_ids, *override.skipped_life_ids}
|
||||
unknown = selected - set(records)
|
||||
if unknown:
|
||||
return lifecycles, f"套餐生命周期覆盖引用不属于该卡的 tbl_card_life 记录: {sorted(unknown)}"
|
||||
|
||||
migratable = {
|
||||
item.life_id for item in records.values()
|
||||
if item.migration_status in mapping.package_rules.migrate_statuses
|
||||
}
|
||||
if selected != migratable:
|
||||
missing = sorted(migratable - selected)
|
||||
extra = sorted(selected - migratable)
|
||||
return lifecycles, f"套餐生命周期覆盖必须完整裁决原本可迁移记录: 未覆盖={missing}, 非可迁移={extra}"
|
||||
|
||||
status_by_id = {override.active_life_id: "active"}
|
||||
status_by_id.update({life_id: "pending" for life_id in override.pending_life_ids})
|
||||
status_by_id.update({life_id: "skipped" for life_id in override.skipped_life_ids})
|
||||
return [
|
||||
replace(
|
||||
item,
|
||||
migration_status=status_by_id[item.life_id],
|
||||
skip_reason="套餐生命周期覆盖指定跳过" if status_by_id.get(item.life_id) == "skipped" else item.skip_reason,
|
||||
) if item.life_id in status_by_id else item
|
||||
for item in lifecycles
|
||||
], ""
|
||||
|
||||
|
||||
def _resolve_runtime_packages(
|
||||
*,
|
||||
cards: list[CardRow],
|
||||
@@ -1184,6 +1222,14 @@ def _resolve_runtime_packages(
|
||||
|
||||
for card, asset_type, asset_identifier in source_cards:
|
||||
lifecycles = package_lifecycles.get(card.iccid_full, [])
|
||||
lifecycles, override_error = _apply_package_lifecycle_override(card.iccid_full, lifecycles, mapping)
|
||||
if override_error:
|
||||
errors.append(ErrorRow("mapping.yaml", 0, "iccid", card.iccid_full, "package_lifecycle_override_invalid", override_error))
|
||||
for item in lifecycles:
|
||||
resolution_rows.append(_package_resolution_row(
|
||||
asset_type, asset_identifier, card.iccid_full, item, "", "", "blocked", override_error
|
||||
))
|
||||
continue
|
||||
migratable = [
|
||||
item for item in lifecycles
|
||||
if item.migration_status in mapping.package_rules.migrate_statuses
|
||||
@@ -1430,6 +1476,7 @@ def _write_package_usages(
|
||||
" package_name,\n"
|
||||
" package_price_config_status, package_is_gift,\n"
|
||||
" data_reset_cycle,\n"
|
||||
" expiry_base_snapshot, calendar_type_snapshot, duration_months_snapshot, duration_days_snapshot,\n"
|
||||
" creator, updater, created_at, updated_at\n"
|
||||
")\n"
|
||||
f"SELECT o.id, o.order_no, p.id, {_sql_str(usage_type)},\n"
|
||||
@@ -1443,6 +1490,9 @@ def _write_package_usages(
|
||||
" p.package_name,\n"
|
||||
" p.price_config_status, p.is_gift,\n"
|
||||
" p.data_reset_cycle,\n"
|
||||
" p.expiry_base, p.calendar_type,\n"
|
||||
" CASE WHEN p.calendar_type = 'natural_month' THEN p.duration_months ELSE 0 END,\n"
|
||||
" CASE WHEN p.calendar_type = 'by_day' THEN p.duration_days ELSE 0 END,\n"
|
||||
f" {user_id}, {user_id}, NOW(), NOW()\n"
|
||||
"FROM tb_order o\n"
|
||||
f"JOIN tb_package p ON p.id = {pkg.target_package_id} AND p.package_type = 'formal' AND p.deleted_at IS NULL\n"
|
||||
|
||||
Reference in New Issue
Block a user