update
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m49s

This commit is contained in:
2026-09-03 09:28:28 +08:00
parent dbfeeee253
commit 370fd3e67f
150 changed files with 64402 additions and 247 deletions

View File

@@ -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([
"",
"# ============== 少量资产级覆盖 ==============",

View 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)

View 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("'", "''") + "'"

View File

@@ -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"