# 奇成数据迁移方案

> 目标：把奇成（旧 MySQL `kyhl` 库）的存量卡和设备迁移到新系统（PostgreSQL），输出 SQL 脚本供线上手动执行。
> 范围：当前态资产 + 当前生效正式套餐 + 未生效待生效正式套餐 + 累计已用流量 + 代理佣金总值。**不迁移历史明细、已过期套餐和加油包**。

---

## 一、整体架构

```
                    业务方提供
                ┌──────────────────────┐
                │ resources/cards.csv  │
                │ resources/devices.csv│
                └──────────┬───────────┘
                           │
                我们维护唯一决策配置
                ┌──────────▼───────────┐
                │ config/mapping.yaml  │
                │ 归属/槽位/套餐映射    │
                └──────────┬───────────┘
                           │
                ┌──────────▼───────────┐
                │   脚本1：资产导入     │  ← 纯离线，不查奇成
                │   migrate_assets.py  │
                └──────────┬───────────┘
                           │
                           ▼
                ┌──────────────────────┐
                │ output/step1_*.sql   │  ← 手动在线上执行
                └──────────┬───────────┘
                           │ (执行完，新库已有这些 iccid)
                           │
                ┌──────────▼───────────┐
                │  脚本2：关联数据导入  │  ← 直连奇成只读查询
                │  migrate_runtime.py  │  ← 直连新库测试环境拿 id
                └──────────┬───────────┘
                           │
                           ▼
                ┌──────────────────────┐
                │ output/step2_*.sql   │  ← 手动在线上执行
                └──────────────────────┘
```

**关键约束**
- 两个脚本都**不直接写入**线上库，只生成 `.sql` 文件和审核 CSV
- `config/mapping.yaml` 是归属、设备当前槽位、套餐来源槽位和套餐迁移范围的决策源；奇成只提供历史事实
- 脚本对奇成线上库**只 SELECT**，DB 连接层强制只读事务（`SET TRANSACTION READ ONLY`）
- 所有 INSERT 使用 `ON CONFLICT DO NOTHING`，支持重跑
- `errors.csv` 非空时不执行 SQL，先修正配置/输入后重跑

---

## 二、业务方提供的资产清单

### 2.1 `resources/cards.csv` 卡资产清单

每张要迁移的卡一行（**包含独立卡和设备上的卡**）。

| 列名 | 必填 | 说明 | 示例 |
|------|------|------|------|
| `iccid` | 是 | ICCID。**19 位或 20 位**，业务方按奇成原值给（脚本根据 carrier_name 反查 carrier_type 判断长度规则） | `89852000263338772439` |
| `msisdn` | 否 | 手机号 / 接入号（奇成 `tbl_card.phone`） | `454006333877243` |
| `carrier_name` | 是 | 奇成的运营商名称，**必须在 `config/carrier.yaml` 中能匹配到** | `移动全国卡` |
| `package_name` | 否 | 当前生效套餐名（奇成 `tbl_card_life.meal_name`）。留空表示无套餐 | `Y星网专享年卡套餐每月300M（12个月）` |
| `package_expires_at` | 否 | 当前套餐到期时间（奇成 `tbl_card_life.expire_date`），格式 `YYYY-MM-DD HH:MM:SS`。无套餐留空 | `2027-04-28 16:00:00` |
| `is_industry` | 是 | 是否行业卡。`true`/`false` | `false` |
| `virtual_no` | 否 | 卡虚拟号（奇成 `tbl_virtual_number.vcode`），无则留空 | `8960066754` |
| `target_shop_code` | 否 | 目标店铺编码（`tb_shop.shop_code`）。**留空 = 进平台库存**；若该卡在设备上则**强制留空**（跟随设备） | `SHOP20260428103424CZZQ` |

### 2.2 `resources/devices.csv` 设备资产清单

每台要迁移的设备一行。

| 列名 | 必填 | 说明 | 示例 |
|------|------|------|------|
| `imei` | 否 | 设备 IMEI（非蜂窝设备可留空） | `860000000000001` |
| `virtual_no` | 是 | 设备虚拟号/别名（业务方自定义，全局唯一） | `DEV-202604-0001` |
| `device_name` | 否 | 设备名称 | `张三家充电宝` |
| `device_model` | 否 | 设备型号 | `CB-PRO-V2` |
| `manufacturer` | 否 | 制造商 | `深圳XX` |
| `max_sim_slots` | 否 | 插槽数，默认 4 | `4` |
| `sim_iccid_1` | 否 | 插槽1的 iccid，必须在 cards.csv 中存在 | `89852000...` |
| `sim_iccid_2` | 否 | 插槽2的 iccid | `89860623...` |
| `sim_iccid_3` | 否 | 插槽3的 iccid | |
| `sim_iccid_4` | 否 | 插槽4的 iccid | |
| `current_slot` | 否 | 当前使用的插槽位置（1-4），用于 `tb_device_sim_binding.is_current`。空值按 `mapping.yaml` 批量规则或覆盖项填充 | `2` |
| `package_source_slot` | 否 | 设备套餐来源槽位（1-4）。只迁该槽位卡的奇成正式套餐为设备套餐 | `2` |

**注意**
- `cards.csv` 必须先到位，`devices.csv` 的 `sim_iccid_*` 才能被校验
- 业务方分别准备这两份是合理的，奇成 `tbl_card_relate`（主卡+副卡1+副卡2）的关系业务方应已掌握
- 槽位解析优先级：`devices.csv` 行级配置 > `mapping.yaml.overrides.devices` > `mapping.yaml.ownership_rules.device` 批量默认值
- `current_slot` 或 `package_source_slot` 对应槽位无卡时写入 `errors.csv` 并阻断该设备相关 SQL

---

## 三、ICCID 19/20 位的处理规则（关键）

新系统设计：`tb_iot_card.iccid_19` 和 `iccid_20` 两个字段。

| 运营商类型 | 标准 ICCID 长度 | iccid_19 | iccid_20 |
|----------|---------------|----------|----------|
| **CTCC（电信）** | 19 位 | = iccid 全值 | NULL |
| **CMCC（移动）** | 20 位 | = iccid 前 19 位 | = iccid 全值 |
| **CUCC（联通）** | 20 位 | = iccid 前 19 位 | = iccid 全值 |
| **CBN（广电）** | 20 位 | = iccid 前 19 位 | = iccid 全值 |

**脚本判定流程**：
```
1. 读 carrier_name → 查 config/carrier.yaml 得到 carrier_type
2. 根据 carrier_type 决定标准长度（CTCC=19, 其他=20）
3. 校验 csv 中 iccid 实际长度：
   - 长度匹配 → 正常拆分
   - 长度=20 但 carrier_type=CTCC → 警告（电信卡居然是 20 位？写入 errors.csv 让业务方确认）
   - 长度=19 但 carrier_type≠CTCC → 警告（缺校验位？写入 errors.csv）
   - 长度 ∉ {19, 20} → 拒绝（奇成里有 18/21/22 位的脏数据，必须剔除）
4. 写 tb_iot_card 时同时填 iccid / iccid_19 / iccid_20
```

异常的 iccid 不进入 SQL 输出，统一写到 `output/errors.csv`，业务方人工修复后重跑。

---

## 四、我们维护的映射配置

迁移前**必须**在新系统建好店铺、运营商、套餐系列、套餐，然后填好 `config/mapping.yaml`。该文件是本次迁移的唯一决策源。

### 4.1 归属、槽位和套餐规则

```yaml
ownership_rules:
  default_target_shop_code: KWTX
  device:
    mode: default_shop
    current_slot: 2
    package_source_slot: 2
  standalone_card:
    mode: default_shop

package_rules:
  migrate_statuses: [active, pending]

overrides:
  devices:
    - virtual_no: "862639073940258"
      target_shop_code: OTHER
      current_slot: 1
      package_source_slot: 1
  cards:
    - iccid: "89861590172420360956"
      target_shop_code: OTHER
```

归属规则：独立卡按 `overrides.cards` 或 `ownership_rules.standalone_card` 决定；设备按 `overrides.devices` 或 `ownership_rules.device` 决定；设备上的卡跟随设备。奇成 `agent_id` 不再作为默认归属来源。

### 4.2 `config/mapping.yaml` 中的 carriers

```yaml
# 奇成的运营商名称（业务方在 cards.csv 中填的）→ 新系统 carrier_id + type
carriers:
  - legacy_name: "移动全国卡"
    target_carrier_id: 1580
    target_carrier_type: CMCC
    target_carrier_name: "移动15-BE20030985258"
  - legacy_name: "电信行业卡"
    target_carrier_id: 1583
    target_carrier_type: CTCC
    target_carrier_name: "电信10-qiangukeji"
  # ... 业务方提供的所有运营商都要列出
```

### 4.3 `config/mapping.yaml` 中的 series

```yaml
# 奇成套餐系列名 → 新系统 series_id
# （根据业务方提供的 package_name，反查 tbl_set_meal → meal_series_id → tbl_set_meal_series.name）
series:
  - legacy_series_name: "SXKJ专享"
    target_series_id: 5
  - legacy_series_name: "移动全国卡月卡"
    target_series_id: 6
```

### 4.4 `config/mapping.yaml` 中的 packages

```yaml
# 奇成套餐名 → 新系统 package_id（cards.csv 的 package_name 直接对照这里）
packages:
  - legacy_package_name: "Y星网专享年卡套餐每月300M（12个月）"
    target_package_id: 123
  - legacy_package_name: "移动全国卡100G（30天）"
    target_package_id: 124
```

> 注：奇成的 `tbl_set_meal` 套餐表非常大，**不需要全部映射**，只映射本次迁移涉及的套餐（脚本预扫描 cards.csv 收集 package_name 集合，缺失的列出来让我们补）。

### 4.5 店铺编码

```yaml
# 如果业务方愿意填 shop_code，本文件可以省略
# 如果业务方只想填别名，这里维护 alias → shop_code 映射
aliases:
  shop_a: "SHOP20260428103424CZZQ"
  shop_b: "SHOP20260429150917OQKA"
```

---

## 五、脚本1：资产导入（`migrate_assets.py`）

### 5.1 输入
- `resources/cards.csv`
- `resources/devices.csv`
- `config/*.yaml`

### 5.2 处理流程

```
1. 加载所有 yaml 映射 → 构建 (legacy_name → target_id) 字典
2. 读 cards.csv：
   a. 行级校验：iccid 格式、carrier_name 是否在映射中、is_industry 合法
   b. 19/20 位拆分（见第三节）
   c. shop_code → shop_id（查新系统测试库 / 我们维护的映射）
   d. 异常行写 errors.csv
3. 读 devices.csv：
   a. 校验 sim_iccid_* 必须在 cards.csv 中存在
   b. 校验设备上的卡其 cards.csv.target_shop_code 必须为空
   c. virtual_no 重复性校验（同设备文件内 + 跨 cards.csv 的 virtual_no）
4. 生成 SQL（见 5.3）
```

### 5.3 输出文件

按表分块，便于逐表 review 和按需重跑：

```
output/
  step1_01_iot_cards.sql        # INSERT INTO tb_iot_card ...
  step1_02_devices.sql          # INSERT INTO tb_device ...
  step1_03_asset_identifiers.sql # INSERT INTO tb_asset_identifier ...
  step1_04_asset_wallets.sql    # INSERT INTO tb_asset_wallet ...
  step1_05_sim_bindings.sql     # INSERT INTO tb_device_sim_binding ...
  step1_06_shop_allocations.sql # INSERT INTO tb_asset_allocation_record（如有 shop_id）
  errors.csv                    # 异常行清单
  summary.txt                   # 统计：处理 X 张卡、Y 台设备、Z 条异常
```

### 5.4 SQL 生成规则

#### 关于 ID 生成

新系统的 PK 是 `bigint` 自增。脚本生成 SQL 时使用 CTE 模式，让 PostgreSQL 自己分配 id：

```sql
-- 示例：插入一张卡，并用 RETURNING 拿到 id，立刻写关联表
WITH inserted_card AS (
  INSERT INTO tb_iot_card (
    iccid, iccid_19, iccid_20, carrier_id, carrier_type, carrier_name,
    card_category, batch_no, status, activation_status, real_name_status,
    network_status, stop_reason, realname_policy, is_standalone,
    msisdn, virtual_no, shop_id, creator, updater, created_at, updated_at
  ) VALUES (
    '89852000263338772439', '8985200026333877243', '89852000263338772439',
    1580, 'CMCC', '移动15-BE20030985258',
    'normal', 'MIGRATION-20260525', 1, 0, 0,
    0, '无套餐', 'after_order', true,
    '454006333877243', '8960066754', NULL, 0, 0, NOW(), NOW()
  )
  ON CONFLICT (iccid) WHERE deleted_at IS NULL DO NOTHING
  RETURNING id
)
-- 同步写 asset_identifier
, ins_ai_iccid AS (
  INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)
  SELECT '89852000263338772439', 'iot_card', id FROM inserted_card
  ON CONFLICT (identifier) DO NOTHING
)
, ins_ai_vno AS (
  INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)
  SELECT '8960066754', 'iot_card', id FROM inserted_card
  WHERE '8960066754' <> ''
  ON CONFLICT (identifier) DO NOTHING
)
-- 同步建钱包
INSERT INTO tb_asset_wallet (
  resource_type, resource_id, balance, frozen_balance,
  currency, status, version, shop_id_tag, created_at, updated_at
)
SELECT 'iot_card', id, 0, 0, 'CNY', 1, 0, COALESCE(0, 0), NOW(), NOW()
FROM inserted_card
ON CONFLICT (resource_type, resource_id) WHERE deleted_at IS NULL DO NOTHING;
```

每张卡一个 CTE 块，脚本生成几万个块。**优势**：原子性 + 幂等 + 不用预先分配 id 段。

#### 关于设备-卡绑定

绑定 SQL 需要先拿到 device.id 和 card.id，用相同的 CTE 模式：

```sql
WITH d AS (
  SELECT id FROM tb_device WHERE virtual_no = 'DEV-202604-0001' LIMIT 1
), c1 AS (
  SELECT id FROM tb_iot_card WHERE iccid = '89852000263338772439' AND deleted_at IS NULL LIMIT 1
)
INSERT INTO tb_device_sim_binding (device_id, iot_card_id, slot_position, bind_status, is_current, bind_time, created_at, updated_at)
SELECT d.id, c1.id, 1, 1, true, NOW(), NOW(), NOW()
FROM d CROSS JOIN c1
ON CONFLICT (device_id, slot_position) WHERE bind_status = 1 AND deleted_at IS NULL DO NOTHING;
-- 触发器自动更新 tb_iot_card.is_standalone = false
```

#### 关于分配记录（shop_id != NULL 的情况）

```sql
-- 在 iot_card 写入 shop_id 的同时，补一条分配记录
WITH c AS (
  SELECT id FROM tb_iot_card WHERE iccid = '...' AND deleted_at IS NULL
)
INSERT INTO tb_asset_allocation_record (
  allocation_no, allocation_type, asset_type, asset_id, asset_identifier,
  from_owner_type, to_owner_type, to_owner_id, operator_id, created_at
)
SELECT 'MIG-' || TO_CHAR(NOW(), 'YYYYMMDDHH24MISS') || '-' || c.id,
       'allocate', 'iot_card', c.id, '...',
       'platform', 'shop', <shop_id>, 0, NOW()
FROM c;
```

---

## 六、脚本2：关联数据导入（`migrate_runtime.py`）

### 6.1 前提
- 脚本1已经执行，新库已有这批 iccid 的卡
- 业务方已确认每张卡当前对应的套餐 ID（在脚本1阶段维护好 `package_mapping.yaml`）

### 6.2 输入
- `resources/cards.csv`（同脚本1）
- `config/*.yaml`（同脚本1，额外加 `config/migration.yaml` 配置 migration_user_id、伪订单前缀等）
- 奇成只读 DSN（连接配置）
- 新系统**测试环境**只读 DSN（仅用于拿 iccid → id 的映射，验证脚本逻辑；线上 id 在 step2 SQL 里用 SELECT 子查询动态取，**不硬编码**）

### 6.3 处理流程

```
1. 读 cards.csv 得到 iccid 列表
2. 连奇成（只读事务）批量查：
   - `tbl_card_life` 当前生效正式套餐和 `tbl_next_month_card_life` 次月待生效套餐，保留 legacy 套餐 ID、套餐名、类型、状态、开始时间、到期时间和稳定排序键
   - `tbl_card.total_bytes_cnt` → 反算新系统真用量 MB
   - `tbl_agent_commission_account` / `tbl_agent` → 仅用于代理钱包初始化
3. 对每个独立卡或设备套餐来源槽位：
   a. 套餐映射：`legacy_meal_id` → `target_package_id`（来自 `mapping.yaml.packages`）
   b. active 套餐写 `tb_package_usage.status=1`，pending 套餐写 `status=0` 并按稳定顺序写 `priority`
   c. 生成伪订单 SQL（order_no = `MIG-<ICCID>-<priority>`）
   d. 生成 `tb_package_usage` SQL（关联伪订单 + 计算 snapshot 字段）
   e. 生成 `UPDATE tb_iot_card SET data_usage_mb = ...`（真用量 MB）
4. 对每个 shop（按代理映射出来的）：
   a. 生成 INSERT/UPDATE tb_agent_wallet（总值）
   b. 生成一条 tb_agent_wallet_transaction（type=initial_migration, amount=总值, remark=源奇成代理ID）
5. 输出 SQL
```

### 6.4 输出文件

```
output/
  step2_01_migration_orders.sql       # 伪订单
  step2_02_package_usages.sql         # 套餐使用记录
  step2_03_card_data_usage_update.sql # 卡累计流量 UPDATE
  step2_04_agent_wallet_init.sql      # 代理钱包总值
  step2_05_agent_wallet_transactions.sql # 迁移流水
  step2_06_asset_series_update.sql     # 资产套餐系列回填
  package_resolution.csv               # 套餐迁移审核文件
  errors.csv                           # 套餐映射缺失、多 active、槽位缺失等阻断错误
  warnings.csv                         # 用量反算和代理钱包警告
  summary.txt
```

### 6.5 伪订单 + 套餐使用记录的 SQL 形态

```sql
-- 伪订单（一条 SQL 同时插订单和 package_usage）
WITH new_order AS (
  INSERT INTO tb_order (
    order_no, order_type, buyer_type, buyer_id, iot_card_id,
    total_amount, payment_method, payment_status, paid_at,
    source, generation, creator, updater, created_at, updated_at
  )
  SELECT 'MIG-89852000263338772439-1',
         'single_card', 'personal', 0, c.id,
         0, 'offline', 2, NOW(),
         'migration', 1, 0, 0, NOW(), NOW()
  FROM tb_iot_card c WHERE c.iccid = '89852000263338772439' AND c.deleted_at IS NULL
  ON CONFLICT (order_no) DO NOTHING
  RETURNING id, order_no, iot_card_id
)
INSERT INTO tb_package_usage (
  order_id, order_no, package_id, usage_type, iot_card_id,
  data_limit_mb, virtual_total_mb_snapshot, display_gain_ratio_snapshot,
  enable_virtual_data_snapshot, status, priority,
  activated_at, expires_at,
  package_name, package_price_config_status, package_is_gift,
  data_reset_cycle, next_reset_at, generation,
  creator, updater, created_at, updated_at
)
SELECT no.id, no.order_no, p.id, 'single_card', no.iot_card_id,
       p.real_data_mb,
       CASE WHEN p.enable_virtual_data THEN p.virtual_data_mb ELSE p.real_data_mb END,
       p.virtual_ratio, p.enable_virtual_data,
       1, 1,
       NULL,                       -- activated_at 奇成多数为 NULL
       '2027-04-28 16:00:00'::timestamp,  -- 从奇成 tbl_card_life.expire_date 拿
       p.package_name, p.price_config_status, p.is_gift,
       p.data_reset_cycle, NULL,
       1, 0, 0, NOW(), NOW()
FROM new_order no
JOIN tb_package p ON p.id = 123 AND p.deleted_at IS NULL  -- package_id 来自映射
ON CONFLICT DO NOTHING;
```

---

## 七、运行流程

### 7.1 准备阶段（在我们的开发/测试机上）

```bash
# 1. 业务方提供
cp 业务方发的*.csv  scripts/migration/resources/

# 2. 我们/业务方在新系统线上建好店铺/运营商/套餐系列/套餐
#    （另行手动完成，本脚本不负责）

# 3. 我们维护映射 yaml
vim scripts/migration/config/*.yaml

# 4. 跑脚本1（离线，只读 csv + yaml）
cd scripts/migration
python migrate_assets.py
# 看 output/errors.csv，找业务方修复后重跑

# 5. 拿 step1 SQL 在测试环境跑一遍，确认无报错
psql -h 测试库 -f output/step1_01_iot_cards.sql
psql -h 测试库 -f output/step1_02_devices.sql
... (按编号顺序)

# 6. 跑脚本2（直连奇成 + 测试环境读 id）
python migrate_runtime.py
# 测试环境跑一遍 step2 SQL，验证最终态

# 7. 验证 OK，把所有 .sql 文件交给运维
```

### 7.2 线上执行（用户手动）

按编号顺序执行：

```bash
# 第一波：资产
psql -h 线上库 -1 -f step1_01_iot_cards.sql
psql -h 线上库 -1 -f step1_02_devices.sql
psql -h 线上库 -1 -f step1_03_asset_identifiers.sql
psql -h 线上库 -1 -f step1_04_asset_wallets.sql
psql -h 线上库 -1 -f step1_05_sim_bindings.sql
psql -h 线上库 -1 -f step1_06_shop_allocations.sql

# 第二波：关联数据
psql -h 线上库 -1 -f step2_01_migration_orders.sql
psql -h 线上库 -1 -f step2_02_package_usages.sql
psql -h 线上库 -1 -f step2_03_card_data_usage_update.sql
psql -h 线上库 -1 -f step2_04_agent_wallet_init.sql
psql -h 线上库 -1 -f step2_05_agent_wallet_transactions.sql
```

`-1` 单事务执行，失败自动回滚整文件。

---

## 八、目录结构（最终交付）

```
scripts/migration/
  README.md                   # 操作手册
  requirements.txt            # python 依赖（pymysql, psycopg2, pyyaml, pandas）
  migrate_assets.py           # 脚本1
  migrate_runtime.py          # 脚本2
  lib/
    legacy_query.py           # 奇成只读查询封装
    csv_loader.py             # csv 读取与校验
    mapping_loader.py         # yaml 映射加载
    iccid_utils.py            # 19/20 位拆分（移植 pkg/utils/iccid.go）
    sql_builder.py            # SQL 生成（CTE 模板）
  resources/
    cards.csv                 # 业务方提供
    devices.csv               # 业务方提供
  config/
    carrier_mapping.yaml
    series_mapping.yaml
    package_mapping.yaml
    shop_mapping.yaml
    migration.yaml            # 全局配置（伪订单前缀、migration_user_id 等）
    legacy_dsn.yaml           # 奇成连接（.gitignore）
    main_test_dsn.yaml        # 新系统测试库连接（.gitignore）
  output/
    step1_*.sql
    step2_*.sql
    errors.csv
    warnings.csv
    summary.txt
```

---

## 九、风险与未决事项

| # | 风险 | 应对 |
|---|------|------|
| 1 | 业务方填的 carrier_name / package_name 与奇成不一致 → 映射失败 | 脚本预扫描，把所有未匹配项写到 unmatched.csv 让业务方修正 |
| 2 | 奇成的 iccid 有 18/21/22 位脏数据 | 脚本第三节规则剔除，进 errors.csv |
| 3 | 同一 iccid 被业务方填多次 | 脚本去重 + 警告 |
| 4 | 设备 `current_slot` 或 `package_source_slot` 指向空槽位 | 写入 errors.csv，阻断该设备资产或套餐 SQL |
| 5 | 同一资产存在多个 active 正式套餐 | 写入 errors.csv，阻断该资产套餐 SQL，人工裁决后修正 |
| 6 | 套餐 ID 映射不全 | `package_resolution.csv` 记录，errors.csv 阻断对应套餐 SQL |
| 7 | tb_iot_card.is_standalone 由触发器维护 | 脚本写入时设 true，触发器会因为后续 binding 自动转 false |
| 8 | 代理→shop 映射不明确 | 需要业务方提供 agent_id → shop_code 映射表，作为 config/agent_shop_mapping.yaml |
| 9 | 线上库 sequence 与测试库不同 | 用 CTE + RETURNING 模式，不硬编码 id |
| 10 | 重复执行 | ON CONFLICT DO NOTHING 保证幂等 |

---

## 十、待决事项的最终答案

### 10.1 代理→店铺映射（业务方提供）

新增 `config/agent_shop_mapping.yaml`，由业务方维护：

```yaml
# 奇成 agent_id → 新系统 shop_code 映射
mappings:
  - legacy_agent_id: "5846ACF63BD94ED2A26072BA5411B197"
    legacy_agent_name: "SX20250721101058"
    target_shop_code: "SHOP20260428103424CZZQ"
  # ...
```

脚本2 通过这份映射把奇成佣金归属到新系统的 shop_id 上。映射不全的奇成代理，其佣金进 `warnings.csv` 让业务方补全。

### 10.2 伪订单字段填法（全部复用现有常量，不动状态机）

`tb_order.buyer_id` 是 `NOT NULL bigint`，**平台自营/赠送场景代码里就是填 0**：
- `internal/service/order/service.go:251`（赠送场景）
- `internal/service/order/service.go:271`（平台自营）

迁移伪订单字段最终值：

| 字段 | 值 | 说明 |
|------|-----|------|
| `order_no` | `MIG-<ICCID>-<priority>` | 前缀就是迁移数据的唯一识别标记 |
| `source` | `'admin'`（复用现有） | 不新增 `migration` 取值 |
| `buyer_type` | `'personal'` | 与 buyer_id=0 配对 |
| `buyer_id` | `0` | 与赠送/平台自营一致 |
| `payment_method` | `'offline'`（复用现有 `model/order.go:99`） | 已有常量，只是字符串快照，不触发状态机 |
| `payment_status` | `2`（已支付） | 避免被支付回调扫到 |
| `paid_at` | `NOW()` | 已支付时间 |
| `total_amount` / `actual_paid_amount` | `0` | 避免汇总统计错乱 |
| `commission_status` | `2`（已完成） | 避免被佣金调度器扫描 |
| `commission_result` | `2`（无佣金） | 不进入佣金链路 |
| `operator_account_type` | `'platform'` | 操作者为平台 |
| `operator_account_name` | `'数据迁移'` | 备注 |

**识别迁移订单**：`WHERE order_no LIKE 'MIG-%'`（不依赖 source 字段）

### 10.3 shop_id_tag：平台库存填 0，有店铺填 shop_id

`tb_asset_wallet.shop_id_tag` 是 `NOT NULL bigint`，代码里平台库存场景就是填 0：
- `internal/service/exchange/service.go:297-300`
- `internal/service/client_order/service.go:723`：`shopTag := 0; if card.ShopID != nil { shopTag = *card.ShopID }`

迁移直接套这个逻辑。同理 `enterprise_id_tag` 可空，统一填 NULL（迁移阶段没有企业归属）。

### 10.4 代理佣金字段映射

新系统 `tb_agent_wallet` 设计是**双钱包**：每个店铺有两条记录，`wallet_type` 区分：
- `wallet_type='main'`：主钱包（充值消费的钱）
- `wallet_type='commission'`：分佣钱包（佣金）

映射关系：

| 新系统字段 | 奇成字段 | 说明 |
|-----------|---------|------|
| `main.balance` | `tbl_agent.balance_money` × 100 | 充值余额（奇成是元，新系统是分） |
| `commission.balance` | `tbl_agent_commission_account.can_draw_amount` × 100 | 当前可提现佣金 |
| `commission.frozen_balance` | `tbl_agent_commission_account.applying_draw_amount` × 100 | 申请中的佣金 |

`total_commision_amount`（累计总佣金）和 `total_draw_amount`（已提现累计）是**历史明细汇总值**，新系统的 `tb_agent_wallet` 没有对应字段；如果业务方需要这些历史数据展示，可以写到迁移流水的 `metadata` JSONB 里作为参考，但不影响余额计算。

每个 shop 生成 **2 条** `tb_agent_wallet`（main + commission）+ **2 条** `tb_agent_wallet_transaction`，全部复用现有常量：

| 字段 | 值 | 说明 |
|------|-----|------|
| `transaction_type` | `'recharge'`（复用） | 表示"初始化余额" |
| `transaction_subtype` | `NULL` | 字段允许 NULL，原本为 deduct 细分预留 |
| `reference_type` | `'topup'`（复用 `pkg/constants/wallet.go:115`） | 与 recharge 配对 |
| `reference_id` | `NULL` | 不关联具体业务，避免误关联 |
| `amount` | 奇成余额 × 100（元→分） | |
| `balance_before` | `0` | 迁移前为 0 |
| `balance_after` | 同 amount | |
| `status` | `1`（成功） | |
| `remark` | `"奇成数据迁移初始化，源 agent_id=<id>, agent_name=<name>"` | 文本识别 |
| `metadata` | `{"source":"qicheng","legacy_agent_id":"...","legacy_field":"can_draw_amount"}` | JSON 结构化识别 |

**识别迁移流水**：`WHERE remark LIKE '%奇成迁移%' OR metadata->>'source' = 'qicheng'`

---

## 十一、不破坏状态机的总体策略

**核心原则**：本次迁移**不新增任何常量**，全部复用现有取值。识别迁移数据靠 `order_no` 前缀 + `remark` / `metadata` 字段，而不是污染枚举字段。

**避免触发后续状态机的关键设置已散落在 10.2/10.4**，汇总如下：
- 伪订单：`payment_status=2`(已支付) + `commission_status=2`(已完成) + `commission_result=2`(无佣金) → 不被支付回调、佣金调度器扫到
- 钱包流水：`reference_id=NULL` → 不与真实业务关联，不被对账任务扫到
- 金额：迁移订单 `total_amount=0`、`actual_paid_amount=0` → 即使被汇总统计扫到也不污染收入数据

**遗留风险**：
- 后台订单列表若按 `source='admin'` 过滤，会看到迁移订单；建议提前做一个常用过滤条件（`order_no NOT LIKE 'MIG-%'`），或者前端订单列表加个"排除迁移数据"开关
- `payment_method='offline'` 在后台报表里会归类到"线下支付"统计中；如果运营对此敏感，需在报表 SQL 中加 `order_no NOT LIKE 'MIG-%'` 过滤

---

## 十二、实施顺序（审查通过后）

1. 业务方启动准备：明确 `cards.csv` / `devices.csv` / `agent_shop_mapping.yaml` 的格式，开始整理
2. 我们这边：
   - 写脚本1（migrate_assets.py + lib + 模板）
   - 在测试库验证脚本1
   - 写脚本2（migrate_runtime.py）
   - 在测试库验证脚本2
   - 写 README.md 操作手册（含"如何在报表中排除迁移数据"提示）
3. 业务方提供数据 → 全流程联调一次 → 交付给运维上线
