All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s
1702 lines
74 KiB
Python
1702 lines
74 KiB
Python
"""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,
|
||
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")
|