迁移脚本的一些问题修复

This commit is contained in:
Break
2026-05-28 17:22:52 +08:00
parent 1550ef3cb1
commit 872769e69e
13 changed files with 328 additions and 114 deletions

View File

@@ -43,6 +43,8 @@ from .mapping_loader import Mapping, MissingTargetError
ORDER_NO_PREFIX_CARD = "MIG-CARD-"
ALLOCATION_NO_PREFIX = "MIG-ALLOC-"
METADATA_SOURCE = "qicheng"
STOP_REASON_NOT_REALNAME = "not_realname"
STOP_REASON_NO_PACKAGE = "no_package"
# ---------------- 通用工具 ----------------
@@ -115,6 +117,53 @@ def _resolve_device_shop_code(
return mapping.lookup_agent_shop_code(meta.agent_id) or ""
def _initial_polling_stop_reason(card: CardRow) -> str:
"""生成迁移卡初始停机原因。
导入时卡默认 network_status=0。停复机服务只会自动复机轮询系统写入的停机原因
因此不能留空;普通卡先等待实名轮询确认,行业卡无需实名则交给套餐轮询重评估。
"""
if card.is_industry:
return STOP_REASON_NO_PACKAGE
return STOP_REASON_NOT_REALNAME
def _device_package_source_iccid(device: DeviceRow) -> str:
"""返回设备级套餐迁移使用的来源卡。
设备套餐在新系统挂到 device_id奇成套餐仍来自卡生命周期表。
优先取当前槽位,缺失时回退 slot1再回退任意已绑定卡。
"""
if not device.sim_iccids:
return ""
return (
device.sim_iccids.get(device.current_slot)
or device.sim_iccids.get(1)
or next(iter(device.sim_iccids.values()), "")
)
def _device_by_virtual_no(devices: list[DeviceRow]) -> dict[str, DeviceRow]:
return {d.virtual_no: d for d in devices}
def _runtime_asset_for_card(card: CardRow, devices_by_virtual_no: dict[str, DeviceRow]) -> tuple[str, str] | None:
"""返回运行态套餐/订单归属:(asset_type, asset_identifier)。
独立卡归属 iot_card设备绑定卡只用设备当前槽位卡生成一条设备级套餐
其他槽位不重复生成,避免同一设备出现多条并列主套餐。
"""
if not card.bound_device_virtual_no:
return "iot_card", card.iccid_full
device = devices_by_virtual_no.get(card.bound_device_virtual_no)
if device is None:
return None
if card.iccid_19 != _device_package_source_iccid(device):
return None
return "device", device.virtual_no
# ============================================================
# 脚本1:资产导入
# ============================================================
@@ -181,24 +230,26 @@ def _filter_cards_by_carrier(
"not_in_legacy", "奇成 tbl_card 中找不到该 ICCID(iccid_19/iccid_20 都没命中)",
))
continue
if not meta.category:
if not meta.account_id:
errors.append(ErrorRow(
"cards.csv", c.line_no, "iccid_19", c.iccid_19,
"no_category", "奇成 tbl_card.category 为空,无法定位运营商映射",
"no_carrier_account", "奇成 tbl_card.account_id 为空,无法定位具体运营商账号映射",
))
continue
carrier = mapping.lookup_carrier(meta.category)
carrier = mapping.lookup_carrier(meta.account_id)
if carrier is None:
errors.append(ErrorRow(
"cards.csv", c.line_no, "category", str(meta.category),
"carrier_not_in_mapping", f"mapping.yaml.carriers 未配置 category={meta.category} ({meta.category_name})",
"cards.csv", c.line_no, "account_id", meta.account_id,
"carrier_not_in_mapping",
f"mapping.yaml.carriers 未配置 account_id={meta.account_id} "
f"({meta.account_name}, category={meta.category}/{meta.category_name})",
))
continue
try:
carrier.require_target()
except MissingTargetError as exc:
errors.append(ErrorRow(
"cards.csv", c.line_no, "category", str(meta.category),
"cards.csv", c.line_no, "account_id", meta.account_id,
"carrier_target_missing", str(exc),
))
continue
@@ -226,7 +277,7 @@ def _write_iot_cards(
f.write(_file_header("step1_01 卡资产导入 (tb_iot_card)"))
for c in cards:
meta = card_metas[c.iccid_19]
carrier = mapping.lookup_carrier(meta.category)
carrier = mapping.lookup_carrier(meta.account_id)
assert carrier is not None # 已被 _filter_cards_by_carrier 保证
# 业务方直接给的两列;iccid 唯一键 = 有 iccid_20 用 iccid_20,否则 iccid_19
@@ -254,7 +305,7 @@ def _write_iot_cards(
f" {_sql_str(card_category)}, {_sql_str(mapping.migration_batch_no)},\n"
" 1, 0, 0, 0,\n"
" 1, 1, TRUE,\n"
" 'after_order', '',\n"
f" 'after_order', {_sql_str(_initial_polling_stop_reason(c))},\n"
f" {_sql_str(meta.msisdn) if meta.msisdn else 'NULL'}, "
f"{_sql_str(meta.virtual_no) if meta.virtual_no else 'NULL'}, "
f"{_shop_id_sub(shop_code)}, {series_id_expr},\n"
@@ -509,6 +560,7 @@ def write_step2(
output_dir: Path,
*,
cards: list[CardRow],
devices: list[DeviceRow],
mapping: Mapping,
card_metas: dict[str, LegacyCardMeta],
usage_snapshots: dict[str, LegacyCardUsage],
@@ -518,6 +570,7 @@ def write_step2(
"""生成 step2_* SQL + warnings.csv + summary.txt。"""
user_id = mapping.migration_user_id
warnings: list[tuple[str, str, str]] = []
devices_by_virtual_no = _device_by_virtual_no(devices)
# 卡 → package(从 card_meta.current_meal_id 经 mapping.packages 查 target)
resolved_packages: dict[str, tuple[int, str]] = {} # iccid_19 → (target_package_id, expire_date)
for c in cards:
@@ -549,9 +602,9 @@ def write_step2(
"(status=1),无法精确反算真用量,按 flow_add_discount=0 处理(真=虚)",
))
_write_migration_orders(output_dir / "step2_01_migration_orders.sql", cards, resolved_packages, user_id)
_write_migration_orders(output_dir / "step2_01_migration_orders.sql", cards, resolved_packages, devices_by_virtual_no, user_id)
_write_package_usages(
output_dir / "step2_02_package_usages.sql", cards, resolved_packages, usage_snapshots, user_id,
output_dir / "step2_02_package_usages.sql", cards, resolved_packages, devices_by_virtual_no, usage_snapshots, user_id,
)
_write_card_data_usage(output_dir / "step2_03_card_data_usage_update.sql", cards, usage_snapshots)
@@ -571,6 +624,7 @@ def write_step2(
output_dir / "summary.txt",
cards=cards,
resolved=resolved_packages,
devices_by_virtual_no=devices_by_virtual_no,
agents=eligible_agents,
warnings=warnings,
)
@@ -590,6 +644,7 @@ def _write_migration_orders(
path: Path,
cards: list[CardRow],
resolved_packages: dict[str, tuple[int, str]],
devices_by_virtual_no: dict[str, DeviceRow],
user_id: int,
) -> None:
"""伪订单关键字段(详见方案文档 10.2):source=admin / buyer_type=personal /
@@ -600,7 +655,36 @@ def _write_migration_orders(
for c in cards:
if c.iccid_19 not in resolved_packages:
continue
asset = _runtime_asset_for_card(c, devices_by_virtual_no)
if asset is None:
continue
asset_type, asset_identifier = asset
order_no = _make_card_order_no(c.iccid_full)
if asset_type == "device":
f.write(
"INSERT INTO tb_order (\n"
" order_no, order_type, buyer_type, buyer_id,\n"
" device_id, asset_identifier,\n"
" total_amount, actual_paid_amount,\n"
" payment_method, payment_status, paid_at,\n"
" commission_status, commission_result, commission_config_version,\n"
" source, generation,\n"
" operator_account_type, operator_account_name,\n"
" creator, updater, created_at, updated_at\n"
")\n"
f"SELECT {_sql_str(order_no)}, 'device', 'personal', 0,\n"
f" d.id, {_sql_str(asset_identifier)},\n"
" 0, 0,\n"
" 'offline', 2, NOW(),\n"
" 2, 2, 0,\n"
" 'admin', 1,\n"
" 'platform', '奇成数据迁移',\n"
f" {user_id}, {user_id}, NOW(), NOW()\n"
"FROM tb_device d\n"
f"WHERE d.virtual_no = {_sql_str(asset_identifier)} AND d.deleted_at IS NULL\n"
"ON CONFLICT (order_no) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
continue
f.write(
"INSERT INTO tb_order (\n"
" order_no, order_type, buyer_type, buyer_id,\n"
@@ -630,6 +714,7 @@ def _write_package_usages(
path: Path,
cards: list[CardRow],
resolved_packages: dict[str, tuple[int, str]],
devices_by_virtual_no: dict[str, DeviceRow],
usage_snapshots: dict[str, LegacyCardUsage],
user_id: int,
) -> None:
@@ -638,6 +723,10 @@ def _write_package_usages(
for c in cards:
if c.iccid_19 not in resolved_packages:
continue
asset = _runtime_asset_for_card(c, devices_by_virtual_no)
if asset is None:
continue
asset_type, _asset_identifier = asset
target_pkg_id, expire = resolved_packages[c.iccid_19]
order_no = _make_card_order_no(c.iccid_full)
expires_expr = f"{_sql_str(expire)}::timestamp" if expire else "NULL"
@@ -648,10 +737,13 @@ def _write_package_usages(
"THEN p.virtual_data_mb ELSE p.real_data_mb END"
)
usage_expr = f"LEAST({used_mb}, {effective_limit_expr})"
carrier_column = "device_id" if asset_type == "device" else "iot_card_id"
carrier_select = "o.device_id" if asset_type == "device" else "o.iot_card_id"
usage_type = "device" if asset_type == "device" else "single_card"
f.write(
"INSERT INTO tb_package_usage (\n"
" order_id, order_no, package_id, usage_type,\n"
" iot_card_id,\n"
f" {carrier_column},\n"
" data_limit_mb, data_usage_mb,\n"
" virtual_total_mb_snapshot, display_gain_ratio_snapshot, enable_virtual_data_snapshot,\n"
" status, priority, generation,\n"
@@ -661,8 +753,8 @@ def _write_package_usages(
" data_reset_cycle,\n"
" creator, updater, created_at, updated_at\n"
")\n"
"SELECT o.id, o.order_no, p.id, 'single_card',\n"
" o.iot_card_id,\n"
f"SELECT o.id, o.order_no, p.id, {_sql_str(usage_type)},\n"
f" {carrier_select},\n"
f" p.real_data_mb, {usage_expr},\n"
f" {effective_limit_expr},\n"
" CASE WHEN p.enable_virtual_data AND p.virtual_data_mb > 0 THEN p.virtual_ratio ELSE 1.0 END,\n"
@@ -832,13 +924,18 @@ def _write_warnings(path: Path, warnings: list[tuple[str, str, str]]) -> None:
writer.writerow([kind, key, message])
def _write_summary_step2(path: Path, *, cards, resolved, agents, warnings) -> None:
def _write_summary_step2(path: Path, *, cards, resolved, devices_by_virtual_no, agents, warnings) -> None:
generated_count = sum(
1
for c in cards
if c.iccid_19 in resolved and _runtime_asset_for_card(c, devices_by_virtual_no) is not None
)
lines = [
"奇成迁移 - 脚本2 关联数据导入摘要",
"=" * 48,
f"生成时间 : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"卡总数 : {len(cards)}",
f" 生成伪订单+套餐 : {len(resolved)}",
f" 生成伪订单+套餐 : {generated_count}",
f"代理钱包初始化 : {len(agents)}",
f"警告条目 : {len(warnings)} (详见 warnings.csv)",
]