From 6c8352491c81c1ffd8c319a3567f4c628d64ebcb Mon Sep 17 00:00:00 2001 From: Break Date: Fri, 29 May 2026 15:08:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/migration/README.md | 4 +- scripts/migration/lib/csv_loader.py | 43 ++++++- scripts/migration/lib/sql_builder.py | 165 ++++++++++++++++++++++++-- scripts/migration/resources/README.md | 8 +- 4 files changed, 200 insertions(+), 20 deletions(-) diff --git a/scripts/migration/README.md b/scripts/migration/README.md index 28e897c..2a3e08e 100644 --- a/scripts/migration/README.md +++ b/scripts/migration/README.md @@ -43,7 +43,7 @@ scripts/migration/ 把两个 CSV 放到 `resources/` 下(命名为 `cards.csv` / `devices.csv`): - **cards.csv**:`iccid_19`/`iccid_20` 至少填一个;仅填 `iccid_20` 时脚本自动派生前 19 位,但奇成主表只按 20 位精确查询;`is_industry` 可选 -- **devices.csv**:`virtual_no` 必填,`sim_iccid_*` 引用 `cards.csv` 中的 ICCID(可填 19 或 20 位) +- **devices.csv**:`imei` / `virtual_no` 至少填一个;`device_name` / `device_model` / `device_type` / `max_sim_slots` 可选;`sim_iccid_*` 引用 `cards.csv` 中的 ICCID(可填 19 或 20 位) 字段说明详见 [`resources/README.md`](resources/README.md)。 @@ -145,7 +145,7 @@ CSV 里**没有** `target_shop_code` 列,所有归属都从 `mapping.agents` 推 |------|---------| | 独立卡 | 奇成 `tbl_card.agent_id` → `mapping.agents[].target_shop_code` | | 设备 | 主卡(`sim_iccid_1`)的 agent_id → 同上 | -| 设备上的卡 | 跟随设备(`shop_id=NULL`,由触发器+绑定关系维护) | +| 设备上的卡 | 跟随设备所属店铺(`shop_id` 与设备一致,设备分配时卡状态同步为已分销) | 如果 agent 在 `mapping.agents` 里 `target_shop_code` 留空 → 该资产进平台库存。 diff --git a/scripts/migration/lib/csv_loader.py b/scripts/migration/lib/csv_loader.py index e32e1d3..ff029e9 100644 --- a/scripts/migration/lib/csv_loader.py +++ b/scripts/migration/lib/csv_loader.py @@ -46,16 +46,19 @@ class CardRow: class DeviceRow: """devices.csv 单行的解析结果。 - 简化为业务方核心关心的列:imei / virtual_no / sim_iccid_*。 + 简化为业务方核心关心的列:imei / virtual_no / device_* / max_sim_slots / sim_iccid_*。 店铺归属由"主卡 sim_iccid_1 的 agent_id → mapping.agents.target_shop_code"推导。 - 其他字段(device_name/device_model/manufacturer 等)直接用默认值,不暴露给业务方。 + 其他字段(manufacturer 等)直接用默认值,不暴露给业务方。 """ line_no: int imei: str virtual_no: str # 留空时用 imei 兜底,见 _load_devices sim_iccids: dict[int, str] # slot → iccid(只保留非空) - max_sim_slots: int = 4 # 默认 4 槽位 + device_name: str = "" + device_model: str = "" + device_type: str = "" + max_sim_slots: int = 1 # 不填时按非空 sim_iccid_* 的最大槽位推导 current_slot: int = 1 # 默认 slot 1 为当前生效 @@ -220,6 +223,9 @@ def _load_devices(path: Path, result: LoadResult) -> None: for idx, raw in enumerate(reader, start=2): imei = _normalize_cell(raw.get("imei", "")) virtual_no = _normalize_cell(raw.get("virtual_no", "")) + device_name = _normalize_cell(raw.get("device_name", "")) + device_model = _normalize_cell(raw.get("device_model", "")) + device_type = _normalize_cell(raw.get("device_type", "")) # virtual_no 留空时用 imei 兜底(业务方说"虚拟号一般会提供,没有就用 imei") effective_vno = virtual_no or imei if not effective_vno: @@ -242,12 +248,43 @@ def _load_devices(path: Path, result: LoadResult) -> None: if v: sim_iccids[slot] = v + max_sim_slots_raw = _normalize_cell(raw.get("max_sim_slots", "")) + if max_sim_slots_raw: + try: + max_sim_slots = int(max_sim_slots_raw) + except ValueError: + result.errors.append( + ErrorRow("devices.csv", idx, "max_sim_slots", max_sim_slots_raw, "invalid", + "最大卡槽数必须是 1-4 的整数") + ) + continue + if max_sim_slots < 1 or max_sim_slots > 4: + result.errors.append( + ErrorRow("devices.csv", idx, "max_sim_slots", max_sim_slots_raw, "invalid", + "最大卡槽数必须在 1-4 之间") + ) + continue + else: + max_sim_slots = max(sim_iccids.keys(), default=1) + + overflow_slots = [slot for slot in sim_iccids if slot > max_sim_slots] + if overflow_slots: + result.errors.append( + ErrorRow("devices.csv", idx, "max_sim_slots", str(max_sim_slots), "slot_overflow", + f"已填写的卡槽 {max(overflow_slots)} 超出最大卡槽数") + ) + continue + result.devices.append( DeviceRow( line_no=idx, imei=imei, virtual_no=effective_vno, + device_name=device_name, + device_model=device_model, + device_type=device_type, sim_iccids=sim_iccids, + max_sim_slots=max_sim_slots, ) ) diff --git a/scripts/migration/lib/sql_builder.py b/scripts/migration/lib/sql_builder.py index 106ee87..f769a4b 100644 --- a/scripts/migration/lib/sql_builder.py +++ b/scripts/migration/lib/sql_builder.py @@ -171,6 +171,23 @@ 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)。 @@ -218,7 +235,7 @@ def write_step1( cards_by_iccid_19 = {c.iccid_19: c for c in valid_cards} - _write_iot_cards(output_dir / "step1_01_iot_cards.sql", valid_cards, card_metas, mapping, user_id) + _write_iot_cards(output_dir / "step1_01_iot_cards.sql", valid_cards, devices, card_metas, mapping, user_id) _write_devices(output_dir / "step1_02_devices.sql", devices, card_metas, mapping, user_id) _write_asset_identifiers(output_dir / "step1_03_asset_identifiers.sql", valid_cards, devices, card_metas) _write_asset_wallets(output_dir / "step1_04_asset_wallets.sql", valid_cards, devices) @@ -292,11 +309,14 @@ def _filter_cards_by_carrier( 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) with path.open("w", encoding="utf-8") as f: f.write(_file_header("step1_01 卡资产导入 (tb_iot_card)")) for c in cards: @@ -309,7 +329,15 @@ def _write_iot_cards( card_category = "industry" if c.is_industry else "normal" shop_code = _resolve_card_shop_code(c, meta, mapping) + if c.bound_device_virtual_no: + device = devices_by_virtual_no.get(c.bound_device_virtual_no) + shop_code = _resolve_device_shop_code(device, card_metas, mapping) if device else "" series_id_expr = _target_series_id_expr_for_iccid_19(c.iccid_19, 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) f.write( "INSERT INTO tb_iot_card (\n" @@ -326,17 +354,35 @@ def _write_iot_cards( 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" - " 1, 0, 0, 0,\n" - " 1, 1, TRUE,\n" + f" {card_status}, 0, 0, 0,\n" + f" 1, 1, {is_standalone},\n" f" 'after_order', {_sql_str(_initial_polling_stop_reason(c))},\n" f" {_sql_str(meta.msisdn) if meta.msisdn else 'NULL'}, " - f"{_sql_str(meta.virtual_no) if meta.virtual_no else 'NULL'}, " - f"{_shop_id_sub(shop_code)}, {series_id_expr},\n" - " '', '',\n" + 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" 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 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( @@ -349,9 +395,9 @@ def _write_devices( """设备表 INSERT。 业务方不关心的字段都写默认值: - device_name/device_model/manufacturer = NULL(可后台编辑) + manufacturer = NULL(可后台编辑) max_sim_slots = 4 - status / asset_status / online_status = 1 / 1 / 0 + status = 有店铺归属则已分销,否则在库;asset_status / online_status = 1 / 0 enable_polling = TRUE,realname_policy = 'after_order' software_version = '',switch_mode = '0'(自动切卡) """ @@ -359,11 +405,14 @@ 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) + 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_19(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" @@ -372,15 +421,53 @@ def _write_devices( ") VALUES (\n" f" {_sql_str(d.virtual_no)}, {d.max_sim_slots}, " f"{_sql_str(d.imei) if d.imei else 'NULL'},\n" - " 1, 1, 1, 0,\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_sub(shop_code)}, {series_id_expr},\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), + ("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"} + set_lines = [ + f"{column} = {value}" if column in sql_literal_columns else f"{column} = {_sql_str(value)}" + for column, value in metadata_updates + ] + diff_checks = [ + 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, @@ -388,21 +475,23 @@ def _write_asset_identifiers( devices: list[DeviceRow], card_metas: dict[str, LegacyCardMeta], ) -> None: - """卡的 iccid + 虚拟号(来自奇成)和设备的 virtual_no 都注册。""" + """卡的 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_19] + 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 meta.virtual_no: + if card_virtual_no: f.write( "INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)\n" - f"SELECT {_sql_str(meta.virtual_no)}, 'iot_card', id FROM tb_iot_card\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" ) @@ -433,6 +522,17 @@ def _write_asset_wallets(path: Path, cards: list[CardRow], devices: list[DeviceR 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" @@ -447,6 +547,17 @@ def _write_asset_wallets(path: Path, cards: list[CardRow], devices: list[DeviceR 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( @@ -475,6 +586,34 @@ def _write_sim_bindings( 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( diff --git a/scripts/migration/resources/README.md b/scripts/migration/resources/README.md index 113888f..d769990 100644 --- a/scripts/migration/resources/README.md +++ b/scripts/migration/resources/README.md @@ -36,13 +36,17 @@ |------|------|------| | `imei` | 否 | 设备 IMEI(非蜂窝设备可留空) | | `virtual_no` | 否 | 设备虚拟号/设备号。留空时用 `imei` 兜底;两者都为空则该行报错 | +| `device_name` | 否 | 设备名称,写入 `tb_device.device_name` | +| `device_model` | 否 | 设备型号,写入 `tb_device.device_model` | +| `device_type` | 否 | 设备类型,写入 `tb_device.device_type` | +| `max_sim_slots` | 否 | 最大卡槽数,范围 1-4。不填时按非空 `sim_iccid_*` 的最大槽位推导 | | `sim_iccid_1` ~ `sim_iccid_4` | 否 | 各插槽 ICCID,引用 `cards.csv` 的卡。可填 19 或 20 位,内部统一按前 19 位回查 `cards.iccid_19`(至少要有 1 个) | **店铺归属规则**:用 `sim_iccid_1`(主卡)的 agent_id 走 `mapping.agents` 推导,与卡归属同口径。 -**其他字段自动取默认值**:`device_name` / `device_model` / `manufacturer` 留空, -`max_sim_slots=4`,`current_slot=1`(以 `sim_iccid_1` 为当前生效卡)。 +**其他字段自动取默认值**:`manufacturer` 留空,`max_sim_slots` 按已填写卡槽推导, +`current_slot=1`(以 `sim_iccid_1` 为当前生效卡)。 ## 一致性约束