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