Files
junhong_cmp_fiber/scripts/migration/lib/sql_builder.py
break b38df737e1
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m51s
先短暂去除限制,上传迁移脚本
2026-07-09 18:23:29 +09:00

1703 lines
74 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.
"""SQL 输出构造。
输出文件:
step1_01_iot_cards.sql - 卡资产
step1_02_devices.sql - 设备资产
step1_03_asset_identifiers.sql- 全局标识符(iccid / virtual_no)
step1_04_asset_wallets.sql - 资产钱包
step1_05_sim_bindings.sql - 设备-卡绑定
step1_06_shop_allocations.sql - 店铺归属的分配记录
step2_01_migration_orders.sql - 迁移伪订单
step2_02_package_usages.sql - 套餐使用记录
step2_03_card_data_usage_update.sql - 卡累计流量 UPDATE
step2_04_agent_wallet_init.sql - 代理钱包初始化(main + commission)
step2_05_agent_wallet_transactions.sql - 代理钱包初始化流水
step2_06_asset_series_update.sql - 资产套餐系列回填
设计原则:
- 所有 INSERT 通过 SELECT 子查询动态取 id,不硬编码新库 id
- 使用 ON CONFLICT (...) WHERE ... DO NOTHING 保证幂等(PG 14+ 支持 partial index 推断)
- 字符串 ' 转义为 ''
- 卡的运营商、号码等历史事实从奇成 fetch_card_meta 取
- 资产归属、当前槽位、旧套餐读取槽位由 mapping.yaml 的 ownership_rules / overrides 决定
"""
from __future__ import annotations
import csv
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Optional
from . import iccid_utils
from .csv_loader import CardRow, DeviceRow, ErrorRow
from .legacy_query import (
LegacyAgentBalance,
LegacyCardMeta,
LegacyCardUsage,
LegacyCommissionAccount,
LegacyPackageLifecycle,
)
from .mapping_loader import Mapping, MissingTargetError
# 写死的默认值(对应原 migration.yaml,实际不需要业务侧关心)
ALLOCATION_NO_PREFIX = "MIG-ALLOC-"
METADATA_SOURCE = "qicheng"
STOP_REASON_NOT_REALNAME = "not_realname"
STOP_REASON_NO_PACKAGE = "no_package"
@dataclass(frozen=True)
class ResolvedPackage:
"""已解析的迁移套餐。"""
source_iccid: str
asset_type: str
asset_identifier: str
legacy_life_id: str
legacy_meal_id: str
legacy_meal_name: str
legacy_meal_type: str
legacy_status: int
target_package_id: int
migration_status: str
package_usage_status: int
priority: int
start_date: str
expire_date: str
stable_sort_key: str
# 完整 ICCID → 多条可迁移套餐
ResolvedPackageMap = dict[str, list[ResolvedPackage]]
@dataclass(frozen=True)
class OwnershipResolution:
"""资产归属解析结果。"""
asset_type: str
asset_identifier: str
source_iccid: str
target_shop_code: str
decision_source: str
should_allocate: bool
reason: str
error_code: str = ""
# ---------------- 通用工具 ----------------
def _sql_str(value: Optional[str]) -> str:
if value is None:
return "NULL"
s = str(value)
return "'" + s.replace("'", "''") + "'"
def _sql_jsonb(d: dict) -> str:
import json
s = json.dumps(d, ensure_ascii=False, separators=(",", ":"))
return "'" + s.replace("'", "''") + "'::jsonb"
def _sql_decimal(value: Decimal) -> str:
"""生成 SQL decimal 字面量,保留奇成小数 MB 精度。"""
return format(value, "f")
def _shop_id_sub(shop_code: Optional[str]) -> str:
"""生成 shop_id 子查询表达式;空 shop_code 返回 'NULL'(平台库存)。"""
if not shop_code:
return "NULL"
return f"(SELECT id FROM tb_shop WHERE shop_code = {_sql_str(shop_code)} AND deleted_at IS NULL LIMIT 1)"
def _now_for_no() -> str:
return datetime.now().strftime("%Y%m%d%H%M%S")
def _file_header(title: str) -> str:
return (
"-- ============================================================\n"
f"-- {title}\n"
f"-- 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
"-- 重跑安全: ON CONFLICT DO NOTHING\n"
"-- 建议执行: psql ... -1 -f <此文件>\n"
"-- ============================================================\n\n"
)
def _resolve_card_ownership(
card: CardRow,
mapping: Mapping,
card_metas: Optional[dict[str, LegacyCardMeta]] = None,
devices_by_virtual_no: Optional[dict[str, DeviceRow]] = None,
) -> OwnershipResolution:
"""解析卡归属。
默认不读取奇成 agent_id;只有 ownership_rules.*.mode 显式配置为 legacy_agent 时,
才按 mapping.agents[].target_shop_code 做批量多店铺分配。
"""
if card.bound_device_virtual_no:
device = (devices_by_virtual_no or {}).get(card.bound_device_virtual_no)
if device is None:
return OwnershipResolution(
asset_type="iot_card",
asset_identifier=card.iccid_full,
source_iccid=card.iccid_full,
target_shop_code="",
decision_source="device_missing",
should_allocate=False,
reason="绑定设备不存在,卡不单独分配",
error_code="bound_device_missing",
)
device_resolution = _resolve_device_ownership(device, mapping, card_metas)
return OwnershipResolution(
asset_type="iot_card",
asset_identifier=card.iccid_full,
source_iccid=card.iccid_full,
target_shop_code=device_resolution.target_shop_code,
decision_source=f"follow_device:{device_resolution.decision_source}",
should_allocate=False,
reason=f"设备绑定卡跟随设备 {device.virtual_no}",
error_code=device_resolution.error_code,
)
override = mapping.lookup_card_override(card.iccid_full, card.iccid_19)
if override is not None:
shop_code = override.target_shop_code or ""
return OwnershipResolution(
asset_type="iot_card",
asset_identifier=card.iccid_full,
source_iccid=card.iccid_full,
target_shop_code=shop_code,
decision_source="override",
should_allocate=bool(shop_code),
reason="卡覆盖项指定目标店铺" if shop_code else "卡覆盖项指定进入平台库存",
)
rule = mapping.ownership_rules.standalone_card
if rule.mode == "legacy_agent":
meta = (card_metas or {}).get(card.iccid_full)
if meta is None or not meta.agent_id:
return OwnershipResolution(
asset_type="iot_card",
asset_identifier=card.iccid_full,
source_iccid=card.iccid_full,
target_shop_code="",
decision_source="legacy_agent",
should_allocate=False,
reason="奇成卡没有 agent_id,进入平台库存",
)
shop_code = mapping.lookup_agent_shop_code(meta.agent_id) or ""
return OwnershipResolution(
asset_type="iot_card",
asset_identifier=card.iccid_full,
source_iccid=card.iccid_full,
target_shop_code=shop_code,
decision_source=f"legacy_agent:{meta.agent_id}",
should_allocate=bool(shop_code),
reason="按奇成 agent_id 映射目标店铺" if shop_code else "agent 未配置目标店铺,进入平台库存",
)
if rule.mode == "default_shop":
shop_code = mapping.ownership_rules.default_target_shop_code or ""
return OwnershipResolution(
asset_type="iot_card",
asset_identifier=card.iccid_full,
source_iccid=card.iccid_full,
target_shop_code=shop_code,
decision_source="default_shop",
should_allocate=bool(shop_code),
reason="独立卡使用批量默认店铺",
)
return OwnershipResolution(
asset_type="iot_card",
asset_identifier=card.iccid_full,
source_iccid=card.iccid_full,
target_shop_code="",
decision_source="none",
should_allocate=False,
reason="独立卡规则指定进入平台库存",
)
def _resolve_card_shop_code(card: CardRow, meta: LegacyCardMeta, mapping: Mapping) -> str:
"""兼容旧调用:返回卡最终 shop_code。"""
return _resolve_card_ownership(card, mapping, {card.iccid_full: meta}).target_shop_code
def _resolve_device_shop_code(
device: DeviceRow,
card_metas: dict[str, LegacyCardMeta],
mapping: Mapping,
) -> str:
"""兼容旧调用:返回设备最终 shop_code。"""
return _resolve_device_ownership(device, mapping, card_metas).target_shop_code
def _resolve_device_ownership(
device: DeviceRow,
mapping: Mapping,
card_metas: Optional[dict[str, LegacyCardMeta]] = None,
) -> OwnershipResolution:
"""解析设备归属。"""
override = mapping.lookup_device_override(device.virtual_no)
source_iccid = device.sim_iccids.get(device.package_source_slot) or ""
if override is not None and override.target_shop_code is not None:
shop_code = override.target_shop_code or ""
return OwnershipResolution(
asset_type="device",
asset_identifier=device.virtual_no,
source_iccid=source_iccid,
target_shop_code=shop_code,
decision_source="override",
should_allocate=bool(shop_code),
reason="设备覆盖项指定目标店铺" if shop_code else "设备覆盖项指定进入平台库存",
)
rule = mapping.ownership_rules.device
if rule.mode == "legacy_agent":
meta = (card_metas or {}).get(source_iccid)
if meta is None or not meta.agent_id:
return OwnershipResolution(
asset_type="device",
asset_identifier=device.virtual_no,
source_iccid=source_iccid,
target_shop_code="",
decision_source="legacy_agent",
should_allocate=False,
reason="设备旧套餐读取槽位绑定卡没有 agent_id,设备进入平台库存",
)
shop_code = mapping.lookup_agent_shop_code(meta.agent_id) or ""
return OwnershipResolution(
asset_type="device",
asset_identifier=device.virtual_no,
source_iccid=source_iccid,
target_shop_code=shop_code,
decision_source=f"legacy_agent:{meta.agent_id}",
should_allocate=bool(shop_code),
reason="按设备旧套餐读取槽位绑定卡的奇成 agent_id 映射目标店铺" if shop_code else "agent 未配置目标店铺,设备进入平台库存",
)
if rule.mode == "default_shop":
shop_code = mapping.ownership_rules.default_target_shop_code or ""
return OwnershipResolution(
asset_type="device",
asset_identifier=device.virtual_no,
source_iccid=source_iccid,
target_shop_code=shop_code,
decision_source="default_shop",
should_allocate=bool(shop_code),
reason="设备使用批量默认店铺",
)
return OwnershipResolution(
asset_type="device",
asset_identifier=device.virtual_no,
source_iccid=source_iccid,
target_shop_code="",
decision_source="none",
should_allocate=False,
reason="设备规则指定进入平台库存",
)
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奇成套餐仍来自卡生命周期表。
旧套餐读取槽位由 devices.csv、设备覆盖项或 ownership_rules.device.package_source_slot 决定。
"""
if not device.sim_iccids:
return ""
return device.sim_iccids.get(device.package_source_slot, "")
def _target_series_id_expr_for_iccid(
iccid: str,
card_metas: dict[str, LegacyCardMeta],
mapping: Mapping,
) -> str:
"""按卡当前套餐映射生成新库套餐系列 SQL 表达式。
系列归属以新系统目标套餐为准,避免奇成套餐系列与新库系列二次映射不一致。
如果旧卡没有当前正式套餐,但本批 mapping 只指定了一个 target_series_id,则按批量兜底写入。
"""
if not iccid:
return "NULL"
meta = card_metas.get(iccid)
if meta is None:
return "NULL"
series_candidates: list[str] = []
if meta.current_meal_id:
pkg_map = mapping.lookup_package(meta.current_meal_id)
if pkg_map is not None and pkg_map.target_package_id:
series_candidates.append(
"(SELECT series_id FROM tb_package "
f"WHERE id = {pkg_map.target_package_id} AND package_type = 'formal' AND deleted_at IS NULL LIMIT 1)"
)
series_map = mapping.lookup_series(getattr(meta, "current_series_id", ""))
if series_map is not None and series_map.target_series_id:
series_candidates.append(str(series_map.target_series_id))
default_series_ids = sorted({
item.target_series_id
for item in mapping.series.values()
if item.target_series_id
})
if len(default_series_ids) == 1 and str(default_series_ids[0]) not in series_candidates:
series_candidates.append(str(default_series_ids[0]))
if not series_candidates:
return "NULL"
return f"COALESCE({', '.join(series_candidates)})"
def _write_formal_package_guard(f, target_pkg_id: int, legacy_meal_name: str) -> None:
"""写目标套餐校验,防止把加油包按主套餐迁入。
迁移脚本绕过 Service 层,必须在 SQL 文件里兜底校验 package_type。
"""
f.write(
"DO $$\n"
"DECLARE\n"
" v_package_type text;\n"
"BEGIN\n"
f" SELECT package_type INTO v_package_type FROM tb_package WHERE id = {target_pkg_id} AND deleted_at IS NULL;\n"
" IF v_package_type IS NULL THEN\n"
" RAISE EXCEPTION USING MESSAGE = "
f"'迁移套餐映射无效: target_package_id={target_pkg_id} 不存在或已删除';\n"
" END IF;\n"
" IF v_package_type <> 'formal' THEN\n"
" RAISE EXCEPTION USING MESSAGE = "
f"'迁移套餐映射禁止指向加油包: legacy_meal_name=' || {_sql_str(legacy_meal_name)} || "
f"', target_package_id={target_pkg_id}, package_type=' || v_package_type;\n"
" END IF;\n"
"END;\n"
"$$;\n\n"
)
def _write_shop_guards(f, shop_codes: set[str]) -> None:
"""写目标店铺存在性校验,防止 shop_code 缺失时静默写入平台库存。"""
for shop_code in sorted(x for x in shop_codes if x):
f.write(
"DO $$\n"
"BEGIN\n"
" IF NOT EXISTS (\n"
" SELECT 1 FROM tb_shop\n"
f" WHERE shop_code = {_sql_str(shop_code)} AND deleted_at IS NULL\n"
" ) THEN\n"
" RAISE EXCEPTION USING MESSAGE = "
f"'迁移目标店铺不存在或已删除: shop_code={shop_code}';\n"
" END IF;\n"
"END;\n"
"$$;\n\n"
)
def _required_shop_codes_for_step1(
cards: list[CardRow],
devices: list[DeviceRow],
mapping: Mapping,
card_metas: Optional[dict[str, LegacyCardMeta]] = None,
) -> set[str]:
"""收集资产导入需要校验的目标店铺码。"""
devices_by_virtual_no = _device_by_virtual_no(devices)
codes = {_resolve_device_ownership(d, mapping, card_metas).target_shop_code for d in devices}
codes.update(_resolve_card_ownership(c, mapping, card_metas, devices_by_virtual_no).target_shop_code for c in cards)
return {code for code in codes if code}
def _device_by_virtual_no(devices: list[DeviceRow]) -> dict[str, DeviceRow]:
return {d.virtual_no: d for d in devices}
def _device_virtual_no_set(devices: list[DeviceRow]) -> set[str]:
return {d.virtual_no for d in devices if d.virtual_no}
def _card_virtual_no_for_import(meta: LegacyCardMeta, device_virtual_nos: set[str]) -> str:
"""返回可安全写入卡表的 virtual_no。
奇成里绑定设备的主卡可能把设备号放在卡 virtual_no 字段。新系统全局标识符要求
设备号归属 device不能再注册成 iot_card否则资产解析会变成卡视角。
"""
if not meta.virtual_no:
return ""
if meta.virtual_no in device_virtual_nos:
return ""
return meta.virtual_no
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_full != _device_package_source_iccid(device):
return None
return "device", device.virtual_no
# ============================================================
# 脚本1:资产导入
# ============================================================
def write_step1(
output_dir: Path,
*,
cards: list[CardRow],
devices: list[DeviceRow],
mapping: Mapping,
card_metas: dict[str, LegacyCardMeta],
duplicate_iccids: set[str],
errors: list[ErrorRow],
) -> None:
"""生成 step1_* SQL + errors.csv + summary.txt。
会就地修改 errors 列表,把映射缺失等问题追加进去。
Args:
card_metas: {完整 ICCID: LegacyCardMeta}
duplicate_iccids: 同一完整 ICCID 在奇成命中多条 tbl_card 记录的集合,直接报错阻断
"""
user_id = mapping.migration_user_id
# 预检 carrier 映射 + 运营商一致性 + 重复命中,把异常卡剔除
valid_cards = _filter_cards_by_carrier(cards, card_metas, duplicate_iccids, mapping, errors)
valid_devices = _filter_devices_by_slots(devices, errors)
cards_by_iccid = {c.iccid_full: c for c in valid_cards}
_write_iot_cards(output_dir / "step1_01_iot_cards.sql", valid_cards, valid_devices, card_metas, mapping, user_id)
_write_devices(output_dir / "step1_02_devices.sql", valid_devices, card_metas, mapping, user_id)
_write_asset_identifiers(output_dir / "step1_03_asset_identifiers.sql", valid_cards, valid_devices, card_metas)
_write_asset_wallets(output_dir / "step1_04_asset_wallets.sql", valid_cards, valid_devices)
_write_sim_bindings(output_dir / "step1_05_sim_bindings.sql", valid_devices, cards_by_iccid)
_write_shop_allocations(
output_dir / "step1_06_shop_allocations.sql", valid_cards, valid_devices, card_metas, mapping, user_id
)
_write_ownership_resolution(output_dir / "ownership_resolution.csv", valid_cards, valid_devices, mapping, card_metas)
_write_errors(output_dir / "errors.csv", errors)
_write_summary_step1(output_dir / "summary.txt", valid_cards, valid_devices, errors, mapping, card_metas)
def _filter_cards_by_carrier(
cards: list[CardRow],
card_metas: dict[str, LegacyCardMeta],
duplicate_iccids: set[str],
mapping: Mapping,
errors: list[ErrorRow],
) -> list[CardRow]:
"""筛掉:奇成查不到 / 命中多条 / carrier 映射缺失 / target_* 未填 / 运营商与 iccid 长度不一致 的卡。"""
valid: list[CardRow] = []
for c in cards:
if c.iccid_full in duplicate_iccids:
errors.append(ErrorRow(
"cards.csv", c.line_no, "iccid", c.iccid_full,
"legacy_duplicate", "奇成 tbl_card 中同一 ICCID 命中多条记录,请业务方人工确认",
))
continue
meta = card_metas.get(c.iccid_full)
if meta is None:
errors.append(ErrorRow(
"cards.csv", c.line_no, "iccid", c.iccid_full,
"not_in_legacy", "奇成 tbl_card 中找不到该 ICCID(iccid_19/iccid_20 都没命中)",
))
continue
if not meta.account_id:
errors.append(ErrorRow(
"cards.csv", c.line_no, "iccid", c.iccid_full,
"no_carrier_account", "奇成 tbl_card.account_id 为空,无法定位具体运营商账号映射",
))
continue
carrier = mapping.lookup_carrier(meta.account_id)
if carrier is None:
errors.append(ErrorRow(
"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, "account_id", meta.account_id,
"carrier_target_missing", str(exc),
))
continue
# 运营商类型与业务方 iccid_20 一致性校验
err = iccid_utils.validate_carrier_iccid_match(c.iccid_20, carrier.target_carrier_type or "")
if err is not None:
errors.append(ErrorRow(
"cards.csv", c.line_no, "iccid_20", c.iccid_20,
err.code, err.message,
))
continue
valid.append(c)
return valid
def _filter_devices_by_slots(devices: list[DeviceRow], errors: list[ErrorRow]) -> list[DeviceRow]:
"""筛掉当前槽位或旧套餐读取槽位无卡的设备。"""
valid: list[DeviceRow] = []
for d in devices:
blocked = False
if d.current_slot not in d.sim_iccids:
errors.append(ErrorRow(
"devices.csv", d.line_no, "current_slot", str(d.current_slot),
"current_slot_empty", f"current_slot={d.current_slot} 对应 sim_iccid_{d.current_slot} 为空",
))
blocked = True
if d.package_source_slot not in d.sim_iccids:
errors.append(ErrorRow(
"devices.csv", d.line_no, "package_source_slot", str(d.package_source_slot),
"package_source_slot_empty",
f"package_source_slot={d.package_source_slot} 对应 sim_iccid_{d.package_source_slot} 为空",
))
blocked = True
if blocked:
continue
valid.append(d)
return valid
def _write_iot_cards(
path: Path,
cards: list[CardRow],
devices: list[DeviceRow],
card_metas: dict[str, LegacyCardMeta],
mapping: Mapping,
user_id: int,
) -> None:
"""写 tb_iot_card。元数据从奇成 card_metas + mapping 推导,iccid_19/iccid_20 用业务方两列。"""
device_virtual_nos = _device_virtual_no_set(devices)
devices_by_virtual_no = _device_by_virtual_no(devices)
shop_codes = _required_shop_codes_for_step1(cards, devices, mapping, card_metas)
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step1_01 卡资产导入 (tb_iot_card)"))
_write_shop_guards(f, shop_codes)
for c in cards:
meta = card_metas[c.iccid_full]
carrier = mapping.lookup_carrier(meta.account_id)
assert carrier is not None # 已被 _filter_cards_by_carrier 保证
# 业务方直接给的两列;iccid 唯一键 = 有 iccid_20 用 iccid_20,否则 iccid_19
iccid_20_expr = _sql_str(c.iccid_20) if c.iccid_20 else "NULL"
card_category = "industry" if c.is_industry else "normal"
shop_code = _resolve_card_ownership(c, mapping, card_metas, devices_by_virtual_no).target_shop_code
series_id_expr = _target_series_id_expr_for_iccid(c.iccid_full, card_metas, mapping)
card_virtual_no = _card_virtual_no_for_import(meta, device_virtual_nos)
is_standalone = "FALSE" if c.bound_device_virtual_no else "TRUE"
device_virtual_no = c.bound_device_virtual_no or ""
card_status = 2 if shop_code else 1
shop_id_expr = _shop_id_sub(shop_code)
# 免停机迁移:代理配置 no_downtime=true 且卡在奇成有当前生效正式套餐
# finalize 保证最后执行,此时 step2 套餐已落库,轮询系统不会在此之前触碰这张卡
is_no_downtime = bool(meta.current_meal_id) and mapping.is_no_downtime_agent(meta.agent_id)
if is_no_downtime:
activation_status = 1
real_name_status = 1 # 卡在线说明已实名,写 1 防止 EvaluateAndAct 因 not_realname 停机
network_status = 1
stop_reason_expr = "NULL"
else:
activation_status = 0
real_name_status = 0
network_status = 0
stop_reason_expr = _sql_str(_initial_polling_stop_reason(c))
f.write(
"INSERT INTO tb_iot_card (\n"
" iccid, iccid_19, iccid_20,\n"
" carrier_id, carrier_type, carrier_name,\n"
" card_category, batch_no,\n"
" status, activation_status, real_name_status, network_status,\n"
" asset_status, generation, is_standalone,\n"
" realname_policy, stop_reason,\n"
" msisdn, virtual_no, shop_id, series_id,\n"
" device_virtual_no, gateway_extend,\n"
" creator, updater, created_at, updated_at\n"
") VALUES (\n"
f" {_sql_str(c.iccid_full)}, {_sql_str(c.iccid_19)}, {iccid_20_expr},\n"
f" {carrier.target_carrier_id}, {_sql_str(carrier.target_carrier_type)}, {_sql_str(carrier.target_carrier_name)},\n"
f" {_sql_str(card_category)}, {_sql_str(mapping.migration_batch_no)},\n"
f" {card_status}, {activation_status}, {real_name_status}, {network_status},\n"
f" 1, 1, {is_standalone},\n"
f" 'after_order', {stop_reason_expr},\n"
f" {_sql_str(meta.msisdn) if meta.msisdn else 'NULL'}, "
f"{_sql_str(card_virtual_no) if card_virtual_no else 'NULL'}, "
f"{shop_id_expr}, {series_id_expr},\n"
f" {_sql_str(device_virtual_no)}, '',\n"
f" {user_id}, {user_id}, NOW(), NOW()\n"
")\n"
"ON CONFLICT (iccid) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
f.write(
"UPDATE tb_iot_card\n"
"SET\n"
f" status = {card_status},\n"
f" shop_id = {shop_id_expr},\n"
f" series_id = COALESCE({series_id_expr}, series_id),\n"
f" virtual_no = {_sql_str(card_virtual_no) if card_virtual_no else 'NULL'},\n"
f" is_standalone = {is_standalone},\n"
f" device_virtual_no = {_sql_str(device_virtual_no)},\n"
" updated_at = NOW()\n"
f"WHERE iccid = {_sql_str(c.iccid_full)} AND deleted_at IS NULL\n"
" AND (\n"
f" status IS DISTINCT FROM {card_status}\n"
f" OR shop_id IS DISTINCT FROM {shop_id_expr}\n"
f" OR ({series_id_expr}) IS NOT NULL AND series_id IS DISTINCT FROM ({series_id_expr})\n"
f" OR virtual_no IS DISTINCT FROM {_sql_str(card_virtual_no) if card_virtual_no else 'NULL'}\n"
f" OR is_standalone IS DISTINCT FROM {is_standalone}\n"
f" OR device_virtual_no IS DISTINCT FROM {_sql_str(device_virtual_no)}\n"
" );\n\n"
)
def _write_devices(
path: Path,
devices: list[DeviceRow],
card_metas: dict[str, LegacyCardMeta],
mapping: Mapping,
user_id: int,
) -> None:
"""设备表 INSERT。
业务方不关心的字段都写默认值:
manufacturer = NULL(可后台编辑)
max_sim_slots = 4
status = 有店铺归属则已分销,否则在库;asset_status / online_status = 1 / 0
enable_polling = TRUE,realname_policy = 'after_order'
software_version = '',switch_mode = '0'(自动切卡)
"""
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step1_02 设备资产导入 (tb_device)"))
_write_shop_guards(f, _required_shop_codes_for_step1([], devices, mapping, card_metas))
for d in devices:
shop_code = _resolve_device_shop_code(d, card_metas, mapping)
device_status = 2 if shop_code else 1
shop_id_expr = _shop_id_sub(shop_code)
source_iccid = _device_package_source_iccid(d)
series_id_expr = _target_series_id_expr_for_iccid(source_iccid, card_metas, mapping)
f.write(
"INSERT INTO tb_device (\n"
" virtual_no, max_sim_slots, imei,\n"
" device_name, device_model, device_type,\n"
" status, asset_status, generation, online_status,\n"
" enable_polling, realname_policy,\n"
" software_version, switch_mode,\n"
" batch_no, shop_id, series_id,\n"
" creator, updater, created_at, updated_at\n"
") VALUES (\n"
f" {_sql_str(d.virtual_no)}, {d.max_sim_slots}, "
f"{_sql_str(d.imei) if d.imei else 'NULL'},\n"
f" {_sql_str(d.device_name) if d.device_name else 'NULL'}, "
f"{_sql_str(d.device_model) if d.device_model else 'NULL'}, "
f"{_sql_str(d.device_type) if d.device_type else 'NULL'},\n"
f" {device_status}, 1, 1, 0,\n"
" TRUE, 'after_order',\n"
" '', '0',\n"
f" {_sql_str(mapping.migration_batch_no)}, {shop_id_expr}, {series_id_expr},\n"
f" {user_id}, {user_id}, NOW(), NOW()\n"
")\n"
"ON CONFLICT (virtual_no) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
metadata_updates = [
(column, value)
for column, value in (
("max_sim_slots", str(d.max_sim_slots)),
("status", str(device_status)),
("shop_id", shop_id_expr),
("series_id", series_id_expr),
("device_name", d.device_name),
("device_model", d.device_model),
("device_type", d.device_type),
)
if value or column in {"max_sim_slots", "status", "shop_id"}
]
if metadata_updates:
sql_literal_columns = {"max_sim_slots", "status", "shop_id", "series_id"}
set_lines = [
f"{column} = COALESCE({value}, {column})" if column == "series_id"
else f"{column} = {value}" if column in sql_literal_columns
else f"{column} = {_sql_str(value)}"
for column, value in metadata_updates
]
diff_checks = [
f"({value}) IS NOT NULL AND {column} IS DISTINCT FROM ({value})" if column == "series_id"
else f"{column} IS DISTINCT FROM {value}"
if column in sql_literal_columns
else f"{column} IS DISTINCT FROM {_sql_str(value)}"
for column, value in metadata_updates
]
set_sql = ",\n ".join(set_lines)
diff_sql = " OR ".join(diff_checks)
f.write(
"UPDATE tb_device\n"
"SET\n"
f" {set_sql},\n"
" updated_at = NOW()\n"
f"WHERE virtual_no = {_sql_str(d.virtual_no)} AND deleted_at IS NULL\n"
f" AND ({diff_sql});\n\n"
)
def _write_asset_identifiers(
path: Path,
cards: list[CardRow],
devices: list[DeviceRow],
card_metas: dict[str, LegacyCardMeta],
) -> None:
"""卡的 iccid + 安全卡虚拟号和设备 virtual_no 都注册。"""
device_virtual_nos = _device_virtual_no_set(devices)
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step1_03 资产标识符注册 (tb_asset_identifier)"))
for c in cards:
meta = card_metas[c.iccid_full]
card_virtual_no = _card_virtual_no_for_import(meta, device_virtual_nos)
f.write(
"INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)\n"
f"SELECT {_sql_str(c.iccid_full)}, 'iot_card', id FROM tb_iot_card\n"
f"WHERE iccid = {_sql_str(c.iccid_full)} AND deleted_at IS NULL\n"
"ON CONFLICT (identifier) DO NOTHING;\n\n"
)
if card_virtual_no:
f.write(
"INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)\n"
f"SELECT {_sql_str(card_virtual_no)}, 'iot_card', id FROM tb_iot_card\n"
f"WHERE iccid = {_sql_str(c.iccid_full)} AND deleted_at IS NULL\n"
"ON CONFLICT (identifier) DO NOTHING;\n\n"
)
for d in devices:
f.write(
"INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)\n"
f"SELECT {_sql_str(d.virtual_no)}, 'device', id FROM tb_device\n"
f"WHERE virtual_no = {_sql_str(d.virtual_no)} AND deleted_at IS NULL\n"
"ON CONFLICT (identifier) DO NOTHING;\n\n"
)
def _write_asset_wallets(path: Path, cards: list[CardRow], devices: list[DeviceRow]) -> None:
"""卡和设备各一条钱包,初始余额 0。"""
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step1_04 资产钱包初始化 (tb_asset_wallet)"))
for c in cards:
f.write(
"INSERT INTO tb_asset_wallet (\n"
" resource_type, resource_id, balance, frozen_balance,\n"
" currency, status, version, shop_id_tag,\n"
" created_at, updated_at\n"
")\n"
"SELECT 'iot_card', id, 0, 0,\n"
" 'CNY', 1, 0, COALESCE(shop_id, 0),\n"
" NOW(), NOW()\n"
"FROM tb_iot_card\n"
f"WHERE iccid = {_sql_str(c.iccid_full)} AND deleted_at IS NULL\n"
"ON CONFLICT (resource_type, resource_id) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
f.write(
"UPDATE tb_asset_wallet w\n"
"SET shop_id_tag = COALESCE(c.shop_id, 0),\n"
" updated_at = NOW()\n"
"FROM tb_iot_card c\n"
"WHERE w.resource_type = 'iot_card'\n"
" AND w.resource_id = c.id\n"
" AND w.deleted_at IS NULL\n"
f" AND c.iccid = {_sql_str(c.iccid_full)} AND c.deleted_at IS NULL\n"
" AND w.shop_id_tag IS DISTINCT FROM COALESCE(c.shop_id, 0);\n\n"
)
for d in devices:
f.write(
"INSERT INTO tb_asset_wallet (\n"
" resource_type, resource_id, balance, frozen_balance,\n"
" currency, status, version, shop_id_tag,\n"
" created_at, updated_at\n"
")\n"
"SELECT 'device', id, 0, 0,\n"
" 'CNY', 1, 0, COALESCE(shop_id, 0),\n"
" NOW(), NOW()\n"
"FROM tb_device\n"
f"WHERE virtual_no = {_sql_str(d.virtual_no)} AND deleted_at IS NULL\n"
"ON CONFLICT (resource_type, resource_id) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
f.write(
"UPDATE tb_asset_wallet w\n"
"SET shop_id_tag = COALESCE(d.shop_id, 0),\n"
" updated_at = NOW()\n"
"FROM tb_device d\n"
"WHERE w.resource_type = 'device'\n"
" AND w.resource_id = d.id\n"
" AND w.deleted_at IS NULL\n"
f" AND d.virtual_no = {_sql_str(d.virtual_no)} AND d.deleted_at IS NULL\n"
" AND w.shop_id_tag IS DISTINCT FROM COALESCE(d.shop_id, 0);\n\n"
)
def _write_sim_bindings(
path: Path,
devices: list[DeviceRow],
cards_by_iccid: dict[str, CardRow],
) -> None:
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step1_05 设备-卡绑定 (tb_device_sim_binding)"))
for d in devices:
for slot, iccid in sorted(d.sim_iccids.items()):
card = cards_by_iccid.get(iccid)
if card is None:
continue # 该卡可能被 carrier 校验剔除,跳过此 slot 绑定
is_current = "TRUE" if slot == d.current_slot else "FALSE"
f.write(
"INSERT INTO tb_device_sim_binding (\n"
" device_id, iot_card_id, slot_position, bind_status, is_current,\n"
" bind_time, creator, updater, created_at, updated_at\n"
")\n"
f"SELECT d.id, c.id, {slot}, 1, {is_current},\n"
" NOW(), 0, 0, NOW(), NOW()\n"
"FROM tb_device d\n"
"CROSS JOIN tb_iot_card c\n"
f"WHERE d.virtual_no = {_sql_str(d.virtual_no)} AND d.deleted_at IS NULL\n"
f" AND c.iccid = {_sql_str(card.iccid_full)} AND c.deleted_at IS NULL\n"
"ON CONFLICT (device_id, slot_position) WHERE bind_status = 1 AND deleted_at IS NULL DO NOTHING;\n\n"
)
f.write(
"UPDATE tb_iot_card c\n"
"SET is_standalone = FALSE,\n"
" device_virtual_no = d.virtual_no,\n"
" shop_id = d.shop_id,\n"
" status = CASE WHEN d.shop_id IS NULL THEN 1 ELSE 2 END,\n"
" updated_at = NOW()\n"
"FROM tb_device d\n"
f"WHERE d.virtual_no = {_sql_str(d.virtual_no)} AND d.deleted_at IS NULL\n"
f" AND c.iccid = {_sql_str(card.iccid_full)} AND c.deleted_at IS NULL\n"
" AND (\n"
" c.is_standalone IS DISTINCT FROM FALSE\n"
" OR c.device_virtual_no IS DISTINCT FROM d.virtual_no\n"
" OR c.shop_id IS DISTINCT FROM d.shop_id\n"
" OR c.status IS DISTINCT FROM CASE WHEN d.shop_id IS NULL THEN 1 ELSE 2 END\n"
" );\n\n"
)
f.write(
"UPDATE tb_asset_wallet w\n"
"SET shop_id_tag = COALESCE(c.shop_id, 0),\n"
" updated_at = NOW()\n"
"FROM tb_iot_card c\n"
f"WHERE c.iccid = {_sql_str(card.iccid_full)} AND c.deleted_at IS NULL\n"
" AND w.resource_type = 'iot_card'\n"
" AND w.resource_id = c.id\n"
" AND w.deleted_at IS NULL\n"
" AND w.shop_id_tag IS DISTINCT FROM COALESCE(c.shop_id, 0);\n\n"
)
def _write_shop_allocations(
path: Path,
cards: list[CardRow],
devices: list[DeviceRow],
card_metas: dict[str, LegacyCardMeta],
mapping: Mapping,
user_id: int,
) -> None:
"""只为带店铺归属的资产生成分配记录;设备上的卡跟随设备分配。"""
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step1_06 店铺分配记录 (tb_asset_allocation_record)"))
_write_shop_guards(f, _required_shop_codes_for_step1(cards, devices, mapping, card_metas))
no_ts = _now_for_no()
for c in cards:
if c.bound_device_virtual_no:
continue
shop_code = _resolve_card_ownership(c, mapping, card_metas, _device_by_virtual_no(devices)).target_shop_code
if not shop_code:
continue
allocation_no = f"{ALLOCATION_NO_PREFIX}CARD-{no_ts}-{c.line_no}"
f.write(
"INSERT INTO tb_asset_allocation_record (\n"
" allocation_no, allocation_type, asset_type, asset_id, asset_identifier,\n"
" from_owner_type, to_owner_type, to_owner_id,\n"
" operator_id, remark,\n"
" creator, updater, created_at, updated_at\n"
")\n"
f"SELECT {_sql_str(allocation_no)}, 'allocate', 'iot_card', c.id, {_sql_str(c.iccid_full)},\n"
" 'platform', 'shop', s.id,\n"
f" {user_id}, '奇成数据迁移初始化分配',\n"
f" {user_id}, {user_id}, NOW(), NOW()\n"
"FROM tb_iot_card c\n"
"CROSS JOIN tb_shop s\n"
f"WHERE c.iccid = {_sql_str(c.iccid_full)} AND c.deleted_at IS NULL\n"
f" AND s.shop_code = {_sql_str(shop_code)} AND s.deleted_at IS NULL;\n\n"
)
for d in devices:
shop_code = _resolve_device_shop_code(d, card_metas, mapping)
if not shop_code:
continue
allocation_no = f"{ALLOCATION_NO_PREFIX}DEV-{no_ts}-{d.line_no}"
f.write(
"INSERT INTO tb_asset_allocation_record (\n"
" allocation_no, allocation_type, asset_type, asset_id, asset_identifier,\n"
" from_owner_type, to_owner_type, to_owner_id,\n"
" operator_id, remark,\n"
" creator, updater, created_at, updated_at\n"
")\n"
f"SELECT {_sql_str(allocation_no)}, 'allocate', 'device', dev.id, {_sql_str(d.virtual_no)},\n"
" 'platform', 'shop', s.id,\n"
f" {user_id}, '奇成数据迁移初始化分配',\n"
f" {user_id}, {user_id}, NOW(), NOW()\n"
"FROM tb_device dev\n"
"CROSS JOIN tb_shop s\n"
f"WHERE dev.virtual_no = {_sql_str(d.virtual_no)} AND dev.deleted_at IS NULL\n"
f" AND s.shop_code = {_sql_str(shop_code)} AND s.deleted_at IS NULL;\n\n"
)
def _write_errors(path: Path, errors: list[ErrorRow]) -> None:
with path.open("w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["source_file", "line_no", "field", "value", "error_code", "message"])
for e in errors:
writer.writerow(e.to_csv_row())
def _write_ownership_resolution(
path: Path,
cards: list[CardRow],
devices: list[DeviceRow],
mapping: Mapping,
card_metas: dict[str, LegacyCardMeta],
) -> None:
"""写资产归属审核文件。"""
devices_by_virtual_no = _device_by_virtual_no(devices)
with path.open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"asset_type",
"asset_identifier",
"source_iccid",
"target_shop_code",
"decision_source",
"should_allocate",
"current_slot",
"current_slot_source",
"package_source_slot",
"package_source_slot_source",
"reason",
])
for d in devices:
item = _resolve_device_ownership(d, mapping, card_metas)
writer.writerow([
item.asset_type,
item.asset_identifier,
item.source_iccid,
item.target_shop_code,
item.decision_source,
"true" if item.should_allocate else "false",
d.current_slot,
d.current_slot_source,
d.package_source_slot,
d.package_source_slot_source,
item.reason,
])
for c in cards:
item = _resolve_card_ownership(c, mapping, card_metas, devices_by_virtual_no)
writer.writerow([
item.asset_type,
item.asset_identifier,
item.source_iccid,
item.target_shop_code,
item.decision_source,
"true" if item.should_allocate else "false",
"",
"",
"",
"",
item.reason,
])
def write_runtime_input_errors(output_dir: Path, errors: list[ErrorRow]) -> None:
"""写运行态迁移的输入错误。"""
_write_errors(output_dir / "errors.csv", errors)
def _write_summary_step1(
path: Path,
cards: list[CardRow],
devices: list[DeviceRow],
errors: list[ErrorRow],
mapping: Mapping,
card_metas: dict[str, LegacyCardMeta],
) -> None:
bound = sum(1 for c in cards if c.bound_device_virtual_no)
devices_by_virtual_no = _device_by_virtual_no(devices)
allocated_cards = sum(
1 for c in cards if _resolve_card_ownership(c, mapping, card_metas, devices_by_virtual_no).target_shop_code
)
allocated_devices = sum(1 for d in devices if _resolve_device_ownership(d, mapping, card_metas).target_shop_code)
lines = [
"奇成迁移 - 脚本1 资产导入摘要",
"=" * 48,
f"生成时间 : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"卡有效总数 : {len(cards)}",
f" 绑设备 : {bound}",
f" 独立卡 : {len(cards) - bound}",
f"设备总数 : {len(devices)}",
f"分配卡数 : {allocated_cards}",
f"分配设备数 : {allocated_devices}",
f"异常行 : {len(errors)} (详见 errors.csv)",
"",
"店铺归属:由 mapping.yaml 的 overrides / ownership_rules / agents 决定,详见 ownership_resolution.csv",
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
# ============================================================
# 脚本2:关联数据导入
# ============================================================
def write_step2(
output_dir: Path,
*,
cards: list[CardRow],
devices: list[DeviceRow],
mapping: Mapping,
card_metas: dict[str, LegacyCardMeta],
usage_snapshots: dict[str, LegacyCardUsage],
package_lifecycles: dict[str, list[LegacyPackageLifecycle]],
commission_snapshots: dict[str, LegacyCommissionAccount],
agent_balances: dict[str, LegacyAgentBalance],
) -> list[str]:
"""生成 step2_* SQL + warnings.csv + summary.txt。"""
user_id = mapping.migration_user_id
warnings: list[tuple[str, str, str]] = []
errors: list[ErrorRow] = []
devices_by_virtual_no = _device_by_virtual_no(devices)
package_resolution_rows: list[list[str]] = []
resolved_packages = _resolve_runtime_packages(
cards=cards,
devices_by_virtual_no=devices_by_virtual_no,
mapping=mapping,
package_lifecycles=package_lifecycles,
errors=errors,
resolution_rows=package_resolution_rows,
)
# 用量预检:虚用量 > 0 但没找到当前生效正式套餐的卡,无法精确反算真用量
# (按 flow_add_discount=0 处理,即真=虚,可能高估真用量;不阻断)
for c in cards:
snap = usage_snapshots.get(c.iccid_full)
if snap is None or snap.virtual_used_mb <= 0:
continue
if not snap.has_active_package:
warnings.append((
"usage_without_active_package", c.iccid_full,
f"奇成虚用量 {snap.virtual_used_mb}MB,但 tbl_card_life 中没有当前生效正式套餐"
"(status=1 且 type!=1),无法精确反算真用量,按 flow_add_discount=0 处理(真=虚)",
))
_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, devices_by_virtual_no, usage_snapshots, user_id,
)
_write_card_data_usage(output_dir / "step2_03_card_data_usage_update.sql", cards, usage_snapshots)
_write_asset_series_update(output_dir / "step2_06_asset_series_update.sql", cards, resolved_packages, devices_by_virtual_no)
# 代理钱包:只为 mapping.agents 中 target_shop_code 已填的项写入
eligible_agents = [a for a in mapping.agents.values() if (a.target_shop_code or "").strip()]
_write_agent_wallet_init(
output_dir / "step2_04_agent_wallet_init.sql",
eligible_agents, agent_balances, commission_snapshots, warnings,
)
_write_agent_wallet_transactions(
output_dir / "step2_05_agent_wallet_transactions.sql",
eligible_agents, agent_balances, commission_snapshots, user_id,
)
_write_errors(output_dir / "errors.csv", errors)
_write_warnings(output_dir / "warnings.csv", warnings)
_write_package_resolution(output_dir / "package_resolution.csv", package_resolution_rows)
_write_summary_step2(
output_dir / "summary.txt",
cards=cards,
resolved=resolved_packages,
errors=errors,
package_resolution_rows=package_resolution_rows,
devices_by_virtual_no=devices_by_virtual_no,
agents=eligible_agents,
warnings=warnings,
)
return [w[2] for w in warnings]
def _resolve_runtime_packages(
*,
cards: list[CardRow],
devices_by_virtual_no: dict[str, DeviceRow],
mapping: Mapping,
package_lifecycles: dict[str, list[LegacyPackageLifecycle]],
errors: list[ErrorRow],
resolution_rows: list[list[str]],
) -> ResolvedPackageMap:
"""解析运行态套餐队列。"""
resolved: ResolvedPackageMap = {}
source_cards: list[tuple[CardRow, str, str]] = []
for card in cards:
asset = _runtime_asset_for_card(card, devices_by_virtual_no)
if asset is None:
if card.bound_device_virtual_no:
resolution_rows.append([
"iot_card", card.iccid_full, card.iccid_full, "", "", "", "", "", "", "",
"skipped", "", "skipped", "非设备旧套餐读取槽位,不读取该卡旧套餐", "", "", "",
])
continue
asset_type, asset_identifier = asset
source_cards.append((card, asset_type, asset_identifier))
for card, asset_type, asset_identifier in source_cards:
lifecycles = package_lifecycles.get(card.iccid_full, [])
migratable = [
item for item in lifecycles
if item.migration_status in mapping.package_rules.migrate_statuses
]
skipped = [item for item in lifecycles if item.migration_status == "skipped"]
for item in skipped:
resolution_rows.append(_package_resolution_row(
asset_type, asset_identifier, card.iccid_full, item, "", "", "skipped", item.skip_reason
))
active_items = [item for item in migratable if item.migration_status == "active"]
if len(active_items) > 1:
msg = "同一资产存在多个当前生效正式套餐,无法唯一裁决"
errors.append(ErrorRow(
"tbl_card_life", 0, "iccid", card.iccid_full,
"multiple_active_packages", f"{msg}: {card.iccid_full}",
))
for item in migratable:
resolution_rows.append(_package_resolution_row(
asset_type, asset_identifier, card.iccid_full, item, "", "", "blocked", msg
))
continue
pending_items = sorted(
[item for item in migratable if item.migration_status == "pending"],
key=lambda item: item.stable_sort_key,
)
ordered_items = active_items + pending_items
for item in ordered_items:
pkg_map = mapping.lookup_package(item.meal_id)
if pkg_map is None:
msg = f"mapping.yaml.packages 未配置 legacy_meal_id={item.meal_id} ({item.meal_name})"
errors.append(ErrorRow("tbl_card_life", 0, "meal_id", item.meal_id, "package_not_in_mapping", msg))
resolution_rows.append(_package_resolution_row(
asset_type, asset_identifier, card.iccid_full, item, "", "", "blocked", msg
))
continue
try:
pkg_map.require_target()
except MissingTargetError as exc:
errors.append(ErrorRow(
"tbl_card_life", 0, "meal_id", item.meal_id, "package_target_missing", str(exc)
))
resolution_rows.append(_package_resolution_row(
asset_type, asset_identifier, card.iccid_full, item, "", "", "blocked", str(exc)
))
continue
priority = 1 if item.migration_status == "active" else len(active_items) + pending_items.index(item) + 1
usage_status = 1 if item.migration_status == "active" else 0
resolved_item = ResolvedPackage(
source_iccid=card.iccid_full,
asset_type=asset_type,
asset_identifier=asset_identifier,
legacy_life_id=item.life_id,
legacy_meal_id=item.meal_id,
legacy_meal_name=item.meal_name or pkg_map.legacy_meal_name,
legacy_meal_type=item.meal_type,
legacy_status=item.status,
target_package_id=pkg_map.target_package_id or 0,
migration_status=item.migration_status,
package_usage_status=usage_status,
priority=priority,
start_date=item.start_date or "",
expire_date=item.expire_date or "",
stable_sort_key=item.stable_sort_key,
)
resolved.setdefault(card.iccid_full, []).append(resolved_item)
resolution_rows.append(_package_resolution_row(
asset_type,
asset_identifier,
card.iccid_full,
item,
str(resolved_item.target_package_id),
str(priority),
item.migration_status,
"已生成迁移套餐",
))
if not ordered_items and not skipped:
resolution_rows.append([
asset_type, asset_identifier, card.iccid_full, "", "", "", "", "", "", "",
"skipped", "", "skipped", "未查询到正式套餐生命周期", "", "", "",
])
return resolved
def _package_resolution_row(
asset_type: str,
asset_identifier: str,
source_iccid: str,
item: LegacyPackageLifecycle,
target_package_id: str,
priority: str,
decision: str,
reason: str,
) -> list[str]:
return [
asset_type,
asset_identifier,
source_iccid,
item.source_table,
item.life_id,
item.meal_id,
item.meal_name,
item.meal_type,
str(item.status),
target_package_id,
item.migration_status,
priority,
decision,
reason,
item.start_date or "",
item.expire_date or "",
item.stable_sort_key,
]
def _make_card_order_no(iccid_full: str, priority: int = 1) -> str:
"""生成稳定迁移订单号。
新库 order_no 限制为 varchar(30)。多套餐队列使用 MIG-<ICCID>-<priority>,
20 位 ICCID + 3 位以内 priority 总长仍小于 30。
"""
return f"MIG-{iccid_full}-{priority}"
def _write_migration_orders(
path: Path,
cards: list[CardRow],
resolved_packages: ResolvedPackageMap,
devices_by_virtual_no: dict[str, DeviceRow],
user_id: int,
) -> None:
"""伪订单关键字段(详见方案文档 10.2):source=admin / buyer_type=personal /
buyer_id=0 / payment_method=offline / payment_status=2 / commission_status=2 /
commission_result=2 / amount=0。"""
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step2_01 迁移伪订单 (tb_order)"))
for c in cards:
packages = resolved_packages.get(c.iccid_full, [])
if not packages:
continue
asset = _runtime_asset_for_card(c, devices_by_virtual_no)
if asset is None:
continue
asset_type, asset_identifier = asset
for pkg in packages:
order_no = _make_card_order_no(c.iccid_full, pkg.priority)
_write_formal_package_guard(f, pkg.target_package_id, pkg.legacy_meal_name)
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"
" iot_card_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)}, 'single_card', 'personal', 0,\n"
f" c.id, {_sql_str(c.iccid_full)},\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_iot_card c\n"
f"WHERE c.iccid = {_sql_str(c.iccid_full)} AND c.deleted_at IS NULL\n"
"ON CONFLICT (order_no) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
def _write_package_usages(
path: Path,
cards: list[CardRow],
resolved_packages: ResolvedPackageMap,
devices_by_virtual_no: dict[str, DeviceRow],
usage_snapshots: dict[str, LegacyCardUsage],
user_id: int,
) -> None:
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step2_02 套餐使用记录 (tb_package_usage)"))
for c in cards:
packages = resolved_packages.get(c.iccid_full, [])
if not packages:
continue
asset = _runtime_asset_for_card(c, devices_by_virtual_no)
if asset is None:
continue
asset_type, _asset_identifier = asset
for pkg in packages:
order_no = _make_card_order_no(c.iccid_full, pkg.priority)
_write_formal_package_guard(f, pkg.target_package_id, pkg.legacy_meal_name)
activated_expr = f"{_sql_str(pkg.start_date)}::timestamp" if pkg.migration_status == "active" and pkg.start_date else "NULL"
expires_expr = f"{_sql_str(pkg.expire_date)}::timestamp" if pkg.expire_date else "NULL"
snap = usage_snapshots.get(c.iccid_full)
active_used_mb = snap.real_used_mb_floor if snap is not None else 0
used_mb = active_used_mb if pkg.migration_status == "active" else 0
effective_limit_expr = (
"CASE WHEN p.enable_virtual_data AND p.virtual_data_mb > 0 "
"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"
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"
" activated_at, expires_at,\n"
" package_name,\n"
" package_price_config_status, package_is_gift,\n"
" data_reset_cycle,\n"
" creator, updater, created_at, updated_at\n"
")\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"
" CASE WHEN p.enable_virtual_data AND p.virtual_data_mb > 0 THEN TRUE ELSE FALSE END,\n"
f" {pkg.package_usage_status}, {pkg.priority}, 1,\n"
f" {activated_expr}, {expires_expr},\n"
" p.package_name,\n"
" p.price_config_status, p.is_gift,\n"
" p.data_reset_cycle,\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"
f"WHERE o.order_no = {_sql_str(order_no)} AND o.deleted_at IS NULL\n"
"ON CONFLICT (order_id, package_id) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
def _write_card_data_usage(path: Path, cards: list[CardRow], usage_snapshots: dict[str, LegacyCardUsage]) -> None:
"""写卡累计流量基线 UPDATE。
三个字段都按"真用量"语义落库:
- ``data_usage_mb``(BIGINT)= ``real_used_mb_floor``,sub-MB 上取整为 1
- ``current_month_usage_mb``(numeric)= ``real_used_mb`` 原始 Decimal,保留小数
- ``last_gateway_reading_mb``(double)= 同上,用作下次轮询增量计算的基线
"""
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step2_03 卡累计流量更新 (tb_iot_card 流量基线)"))
for c in cards:
snap = usage_snapshots.get(c.iccid_full)
if snap is None or snap.real_used_mb <= 0:
continue
real_decimal = _sql_decimal(snap.real_used_mb)
real_int = snap.real_used_mb_floor
f.write(
"UPDATE tb_iot_card\n"
"SET\n"
f" data_usage_mb = {real_int},\n"
f" current_month_usage_mb = {real_decimal},\n"
f" last_gateway_reading_mb = {real_decimal},\n"
" current_month_start_date = COALESCE(current_month_start_date, DATE_TRUNC('month', NOW())::date),\n"
" last_data_check_at = NOW(),\n"
" last_sync_time = NOW(),\n"
" updated_at = NOW()\n"
f"WHERE iccid = {_sql_str(c.iccid_full)} AND deleted_at IS NULL;\n\n"
)
def _write_asset_series_update(
path: Path,
cards: list[CardRow],
resolved_packages: ResolvedPackageMap,
devices_by_virtual_no: dict[str, DeviceRow],
) -> None:
"""按迁移套餐回填资产套餐系列。
step1 的 INSERT 遇到已存在资产会 DO NOTHING所以这里用幂等 UPDATE 覆盖已导入资产的漏写场景。
"""
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step2_06 资产套餐系列回填 (tb_iot_card / tb_device)"))
for c in cards:
packages = resolved_packages.get(c.iccid_full, [])
if not packages:
continue
primary_pkg = packages[0]
target_pkg_id = primary_pkg.target_package_id
_write_formal_package_guard(f, target_pkg_id, primary_pkg.legacy_meal_name)
f.write(
"UPDATE tb_iot_card c\n"
"SET series_id = p.series_id,\n"
" updated_at = NOW()\n"
"FROM tb_package p\n"
f"WHERE c.iccid = {_sql_str(c.iccid_full)} AND c.deleted_at IS NULL\n"
f" AND p.id = {target_pkg_id} AND p.package_type = 'formal' AND p.deleted_at IS NULL\n"
" AND p.series_id IS NOT NULL\n"
" AND c.series_id IS DISTINCT FROM p.series_id;\n\n"
)
asset = _runtime_asset_for_card(c, devices_by_virtual_no)
if asset is None:
continue
asset_type, asset_identifier = asset
if asset_type == "device":
f.write(
"UPDATE tb_device d\n"
"SET series_id = p.series_id,\n"
" updated_at = NOW()\n"
"FROM tb_package p\n"
f"WHERE d.virtual_no = {_sql_str(asset_identifier)} AND d.deleted_at IS NULL\n"
f" AND p.id = {target_pkg_id} AND p.package_type = 'formal' AND p.deleted_at IS NULL\n"
" AND p.series_id IS NOT NULL\n"
" AND d.series_id IS DISTINCT FROM p.series_id;\n\n"
)
def _write_agent_wallet_init(
path: Path,
eligible_agents,
agent_balances: dict[str, LegacyAgentBalance],
commission_snapshots: dict[str, LegacyCommissionAccount],
warnings: list[tuple[str, str, str]],
) -> None:
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step2_04 代理钱包初始化 (tb_agent_wallet, main + commission)"))
for a in eligible_agents:
balance = agent_balances.get(a.legacy_agent_id)
commission = commission_snapshots.get(a.legacy_agent_id)
if balance is None and commission is None:
warnings.append((
"agent_no_legacy", a.legacy_agent_id,
f"奇成代理 {a.legacy_agent_id} ({a.legacy_agent_name}) 没有 tbl_agent / 佣金账户记录",
))
continue
main_balance = balance.balance_fen if balance else 0
comm_balance = commission.can_draw_amount_fen if commission else 0
comm_frozen = commission.applying_draw_amount_fen if commission else 0
shop_sub = _shop_id_sub(a.target_shop_code)
f.write(
"INSERT INTO tb_agent_wallet (\n"
" shop_id, wallet_type, balance, frozen_balance,\n"
" currency, status, version, shop_id_tag,\n"
" created_at, updated_at\n"
") VALUES (\n"
f" {shop_sub}, 'main', {main_balance}, 0,\n"
f" 'CNY', 1, 0, {shop_sub},\n"
" NOW(), NOW()\n"
")\n"
"ON CONFLICT (shop_id, wallet_type) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
f.write(
"INSERT INTO tb_agent_wallet (\n"
" shop_id, wallet_type, balance, frozen_balance,\n"
" currency, status, version, shop_id_tag,\n"
" created_at, updated_at\n"
") VALUES (\n"
f" {shop_sub}, 'commission', {comm_balance}, {comm_frozen},\n"
f" 'CNY', 1, 0, {shop_sub},\n"
" NOW(), NOW()\n"
")\n"
"ON CONFLICT (shop_id, wallet_type) WHERE deleted_at IS NULL DO NOTHING;\n\n"
)
def _write_agent_wallet_transactions(
path: Path,
eligible_agents,
agent_balances: dict[str, LegacyAgentBalance],
commission_snapshots: dict[str, LegacyCommissionAccount],
user_id: int,
) -> None:
with path.open("w", encoding="utf-8") as f:
f.write(_file_header("step2_05 代理钱包初始化流水 (tb_agent_wallet_transaction)"))
for a in eligible_agents:
balance = agent_balances.get(a.legacy_agent_id)
commission = commission_snapshots.get(a.legacy_agent_id)
if balance is None and commission is None:
continue
main_amount = balance.balance_fen if balance else 0
comm_amount = commission.can_draw_amount_fen if commission else 0
comm_frozen = commission.applying_draw_amount_fen if commission else 0
shop_sub = _shop_id_sub(a.target_shop_code)
for wallet_type, amount in (("main", main_amount), ("commission", comm_amount)):
if amount <= 0:
continue
meta = {
"source": METADATA_SOURCE,
"legacy_agent_id": a.legacy_agent_id,
"legacy_agent_name": a.legacy_agent_name,
"legacy_field": "balance_money" if wallet_type == "main" else "can_draw_amount",
}
if wallet_type == "commission":
meta["legacy_applying_draw_amount_fen"] = comm_frozen
if commission:
meta["legacy_total_commission_fen"] = commission.total_commission_amount_fen
meta["legacy_total_draw_fen"] = commission.total_draw_amount_fen
remark = (
f"奇成数据迁移初始化({'主钱包' if wallet_type == 'main' else '分佣钱包'})"
f"源 agent_id={a.legacy_agent_id}"
)
f.write(
"INSERT INTO tb_agent_wallet_transaction (\n"
" agent_wallet_id, shop_id, user_id,\n"
" transaction_type, amount, balance_before, balance_after,\n"
" status, reference_type, reference_id,\n"
" remark, metadata,\n"
" creator, shop_id_tag, created_at, updated_at\n"
")\n"
f"SELECT w.id, w.shop_id, {user_id},\n"
f" 'recharge', {amount}, 0, {amount},\n"
" 1, 'topup', NULL,\n"
f" {_sql_str(remark)}, {_sql_jsonb(meta)},\n"
f" {user_id}, w.shop_id, NOW(), NOW()\n"
"FROM tb_agent_wallet w\n"
f"WHERE w.shop_id = {shop_sub}\n"
f" AND w.wallet_type = {_sql_str(wallet_type)}\n"
" AND w.deleted_at IS NULL\n"
" AND NOT EXISTS (\n"
" SELECT 1 FROM tb_agent_wallet_transaction t\n"
" WHERE t.agent_wallet_id = w.id\n"
f" AND t.metadata->>'source' = {_sql_str(METADATA_SOURCE)}\n"
f" AND t.metadata->>'legacy_agent_id' = {_sql_str(a.legacy_agent_id)}\n"
f" AND t.metadata->>'legacy_field' = {_sql_str(meta['legacy_field'])}\n"
" AND t.deleted_at IS NULL\n"
" );\n\n"
)
def _write_warnings(path: Path, warnings: list[tuple[str, str, str]]) -> None:
with path.open("w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["kind", "key", "message"])
for kind, key, message in warnings:
writer.writerow([kind, key, message])
def _write_package_resolution(path: Path, rows: list[list[str]]) -> None:
"""写套餐解析审核文件。"""
with path.open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"asset_type",
"asset_identifier",
"source_iccid",
"source_table",
"legacy_life_id",
"legacy_meal_id",
"legacy_meal_name",
"legacy_meal_type",
"legacy_status",
"target_package_id",
"target_status",
"priority",
"decision",
"reason",
"start_date",
"expire_date",
"stable_sort_key",
])
writer.writerows(rows)
def _write_summary_step2(path: Path, *, cards, resolved, errors, package_resolution_rows, devices_by_virtual_no, agents, warnings) -> None:
generated_count = sum(
len(resolved.get(c.iccid_full, []))
for c in cards
if _runtime_asset_for_card(c, devices_by_virtual_no) is not None
)
active_count = sum(1 for items in resolved.values() for item in items if item.migration_status == "active")
pending_count = sum(1 for items in resolved.values() for item in items if item.migration_status == "pending")
skipped_count = sum(1 for row in package_resolution_rows if len(row) > 11 and row[11] == "skipped")
lines = [
"奇成迁移 - 脚本2 关联数据导入摘要",
"=" * 48,
f"生成时间 : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"卡总数 : {len(cards)}",
f" 生成伪订单+套餐 : {generated_count}",
f" active 套餐 : {active_count}",
f" pending 套餐 : {pending_count}",
f" 跳过套餐 : {skipped_count}",
f"错误条目 : {len(errors)} (详见 errors.csv)",
f"代理钱包初始化 : {len(agents)}",
f"警告条目 : {len(warnings)} (详见 warnings.csv)",
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")