diff --git a/scripts/migration/README.md b/scripts/migration/README.md index 8a2262c..28e897c 100644 --- a/scripts/migration/README.md +++ b/scripts/migration/README.md @@ -88,6 +88,7 @@ psql -h $HOST -1 -f output/step2_02_package_usages.sql psql -h $HOST -1 -f output/step2_03_card_data_usage_update.sql psql -h $HOST -1 -f output/step2_04_agent_wallet_init.sql psql -h $HOST -1 -f output/step2_05_agent_wallet_transactions.sql +psql -h $HOST -1 -f output/step2_06_asset_series_update.sql ``` `-1` = 单事务,失败回滚整文件;`ON CONFLICT DO NOTHING` 保证重跑安全。 @@ -125,7 +126,7 @@ packages: # 同理 legacy_meal_name: "三网充电宝全国畅玩年卡套餐..." target_package_id: 2 ← 必填:新库 tb_package.id -series: # 可选(留 null 表示不写入 tb_iot_card.series_id) +series: # 仅保留奇成套餐系列快照,生成器不要求填写 - legacy_series_id: "..." legacy_series_name: "国内卡 无预存30天" target_series_id: null @@ -209,6 +210,8 @@ real_used_mb = total_bytes_cnt / (1 + flow_add_discount / 100) 设备上的卡在新系统运行态会按设备维度判断有效套餐和复机条件,因此迁移时只用设备当前槽位卡(默认 `sim_iccid_1`)作为奇成套餐来源,生成一条 `usage_type=device`、`device_id=设备ID` 的套餐使用记录。其他槽位绑定卡不重复生成主套餐,避免同一设备出现多条并列生效主套餐。 +设备和卡的 `series_id` 以新系统目标套餐为准:脚本通过 `mapping.packages[].target_package_id` 对应的 `tb_package.series_id` 写入资产,并额外生成 `step2_06_asset_series_update.sql`,用于修复已经导入但因 `ON CONFLICT DO NOTHING` 没有被 step1 更新到的资产。 + ## 7. 重要约束 - ❗ 所有奇成查询走 `SET TRANSACTION READ ONLY`,严禁误写。 diff --git a/scripts/migration/config/mapping.yaml.example b/scripts/migration/config/mapping.yaml.example index b673dd6..1652f9a 100644 --- a/scripts/migration/config/mapping.yaml.example +++ b/scripts/migration/config/mapping.yaml.example @@ -34,12 +34,12 @@ packages: legacy_meal_name: "三网充电宝全国畅玩年卡套餐1000G/月(12个月)" target_package_id: null # 必填:新库 tb_package.id -# ============== 套餐系列映射(可选) ============== -# 仅当你需要给迁移卡设置 tb_iot_card.series_id 时才填 +# ============== 套餐系列快照(可选) ============== +# 仅保留奇成套餐系列供人工参考;资产 series_id 会从 target_package_id 对应的 tb_package.series_id 反查 series: - legacy_series_id: "95A038B9D3014F9DA1539EC092D9DBAA" legacy_series_name: "国内卡 无预存30天" - target_series_id: null # 可选:新库 tb_package_series.id,null=不写入 + target_series_id: null # 可选参考字段,生成 SQL 不依赖这里 # ============== 代理-店铺映射 ============== # scan_legacy 会按"奇成 tbl_card.agent_id"分组写入,你只需填 target_shop_code diff --git a/scripts/migration/lib/sql_builder.py b/scripts/migration/lib/sql_builder.py index 3c195d8..106ee87 100644 --- a/scripts/migration/lib/sql_builder.py +++ b/scripts/migration/lib/sql_builder.py @@ -13,6 +13,7 @@ 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 @@ -143,6 +144,29 @@ def _device_package_source_iccid(device: DeviceRow) -> str: ) +def _target_series_id_expr_for_iccid_19( + iccid_19: str, + card_metas: dict[str, LegacyCardMeta], + mapping: Mapping, +) -> str: + """按卡当前套餐映射生成新库套餐系列 SQL 表达式。 + + 系列归属以新系统目标套餐为准,避免奇成套餐系列与新库系列二次映射不一致。 + """ + if not iccid_19: + return "NULL" + meta = card_metas.get(iccid_19) + if meta is None or not meta.current_meal_id: + return "NULL" + pkg_map = mapping.lookup_package(meta.current_meal_id) + if pkg_map is None or not pkg_map.target_package_id: + return "NULL" + return ( + "(SELECT series_id FROM tb_package " + f"WHERE id = {pkg_map.target_package_id} AND deleted_at IS NULL LIMIT 1)" + ) + + def _device_by_virtual_no(devices: list[DeviceRow]) -> dict[str, DeviceRow]: return {d.virtual_no: d for d in devices} @@ -285,8 +309,7 @@ def _write_iot_cards( card_category = "industry" if c.is_industry else "normal" shop_code = _resolve_card_shop_code(c, meta, mapping) - series = mapping.lookup_series(_meal_to_series_id(meta, mapping)) - series_id_expr = str(series.target_series_id) if series and series.target_series_id else "NULL" + series_id_expr = _target_series_id_expr_for_iccid_19(c.iccid_19, card_metas, mapping) f.write( "INSERT INTO tb_iot_card (\n" @@ -316,19 +339,6 @@ def _write_iot_cards( ) -def _meal_to_series_id(meta: LegacyCardMeta, mapping: Mapping) -> str: - """由 meta 的 meal_id 经 mapping.packages 间接拿不到 series,所以这里直接靠 - mapping.series 用 legacy_series_id 反查就行。简化:返回空字符串(让上层走 series 映射)。 - - 实际上 series_id 仅在套餐有 meal_series_id 时才有意义。考虑到 LegacyCardMeta 没存 - series_id,这里返回 '' — 让 series_id_expr 走 NULL 分支。后续若需要,scan_legacy - 可把 series_id 也下沉到 card_meta。 - """ - _ = meta - _ = mapping - return "" - - def _write_devices( path: Path, devices: list[DeviceRow], @@ -349,13 +359,15 @@ def _write_devices( f.write(_file_header("step1_02 设备资产导入 (tb_device)")) for d in devices: shop_code = _resolve_device_shop_code(d, card_metas, mapping) + source_iccid = _device_package_source_iccid(d) + series_id_expr = _target_series_id_expr_for_iccid_19(source_iccid, card_metas, mapping) f.write( "INSERT INTO tb_device (\n" " virtual_no, max_sim_slots, imei,\n" " status, asset_status, generation, online_status,\n" " enable_polling, realname_policy,\n" " software_version, switch_mode,\n" - " batch_no, shop_id,\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}, " @@ -363,7 +375,7 @@ def _write_devices( " 1, 1, 1, 0,\n" " TRUE, 'after_order',\n" " '', '0',\n" - f" {_sql_str(mapping.migration_batch_no)}, {_shop_id_sub(shop_code)},\n" + f" {_sql_str(mapping.migration_batch_no)}, {_shop_id_sub(shop_code)}, {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" @@ -607,6 +619,7 @@ def write_step2( 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()] @@ -802,6 +815,50 @@ def _write_card_data_usage(path: Path, cards: list[CardRow], usage_snapshots: di ) +def _write_asset_series_update( + path: Path, + cards: list[CardRow], + resolved_packages: dict[str, tuple[int, str]], + 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: + if c.iccid_19 not in resolved_packages: + continue + target_pkg_id, _expire = resolved_packages[c.iccid_19] + 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.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.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,