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