## Context

当前奇成迁移脚本分为 `scan_legacy.py`、`migrate_assets.py`、`migrate_runtime.py`。脚本可以生成卡、设备、绑定、分配、伪订单、套餐使用记录和系列回填 SQL，但多个关键决策依赖奇成查询结果和脚本默认值：

- 设备归属由 `sim_iccid_1` 对应卡的奇成 `agent_id` 推导。
- 设备套餐来源由 `current_slot` 决定，但 CSV 未提供时默认 `1`。
- 套餐迁移只解析一条当前正式套餐，无法覆盖“当前生效 + 未生效待生效”的队列。
- 卡和设备共用一套套餐解析路径，导致设备绑定卡、独立卡、卡级系列和设备级系列边界不清。
- 缺失代理映射或未命中奇成归属时，脚本会把资产留在平台库存，无法体现人工指定的迁移目标。

本次变更只涉及 `scripts/migration/` 下的离线迁移脚本和文档，不改变线上 Go API、Handler/Service/Store/Model 分层和业务数据库结构。

## Goals / Non-Goals

**Goals:**
- 让 `mapping.yaml` 成为迁移决策源：资产给哪个店铺、设备当前槽位、套餐来源槽位、套餐迁移范围都由配置决定。
- 支持万级迁移的批量规则：默认店铺、设备/独立卡分支规则、按槽位规则、少量资产级覆盖。
- 拆分单卡迁移与设备迁移分支，避免独立卡和设备绑定卡共用不清晰逻辑。
- 迁移当前生效和未生效待生效正式套餐，分别写入 `tb_package_usage.status=1` 和 `status=0`。
- 为每次生成 SQL 输出可审计 CSV，让业务和开发能在执行前确认归属、套餐映射和跳过原因。
- 保持奇成连接只读、SQL 幂等、人工执行和当前脚本目录内的轻量实现方式。

**Non-Goals:**
- 不新增后台 API 或前端页面。
- 不新增数据库表或修改线上业务模型。
- 不迁移历史已过期套餐、加油包、历史订单明细或历史流量日明细。
- 不逐资产要求配置店铺；逐资产配置仅用于异常覆盖。
- 不新增自动化测试或 `_test.go`。

## Decisions

### 决策 1：使用“批量规则 + 覆盖项”的配置模型

**选择**：在 `mapping.yaml` 中新增 `ownership_rules`、`package_rules`、`overrides` 三类配置。

示例：

```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
  packages:
    - legacy_meal_id: D6695908379395072
      target_package_id: 40

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

**理由**：单批上万资产不能逐条配置，默认规则覆盖 99% 场景，覆盖项只处理少量例外。

**替代方案**：继续使用 `mapping.agents` 从奇成代理推导店铺。否决原因：奇成代理不是本次迁移的权威归属，且无代理或代理未映射时会出现静默漏分配。

### 决策 2：拆分独立卡与设备套餐分支

**选择**：
- 独立卡：只为未绑定设备的卡生成 `usage_type='single_card'`。
- 设备：只按设备的 `package_source_slot` 对应卡生成 `usage_type='device'`，其他槽位卡只做绑定和卡基础信息，不重复生成主套餐。

**理由**：新系统运行态按设备维度判断设备套餐和复机条件，设备内多卡不能重复生成并列主套餐。独立卡和设备套餐生命周期也需要不同的资产定位字段。

**替代方案**：继续逐卡生成套餐后再靠 `_runtime_asset_for_card()` 折叠。否决原因：折叠逻辑隐蔽，难以解释某张卡为什么迁或不迁。

### 决策 3：完整查询正式套餐生命周期并映射为 active/pending 队列

**选择**：新增查询函数读取每张来源卡在奇成 `tbl_card_life` 中所有正式套餐记录，排除 `type=1` 加油包和已过期记录，按当前时间和状态识别：
- 当前生效：写入 `status=1`，保留 `activated_at`、`expires_at` 和已用量。
- 未生效待生效：写入 `status=0`，`data_usage_mb=0`，按开始/到期时间和队列顺序设置 `priority`。

**理由**：新系统已支持 `PackageUsageStatusPending=0` 和队列激活，能承接待生效套餐。当前脚本只取一条最大过期时间套餐，解释不了“20G/100G 只迁一个”这类问题。

**替代方案**：只迁当前生效套餐。否决原因：业务已确认必须迁移当前生效 + 未生效待生效套餐。

### 决策 4：生成审核 CSV 作为执行前合同

**选择**：每次生成 SQL 时输出：
- `ownership_resolution.csv`：资产类型、资产标识、来源卡、目标店铺、决策来源、是否分配、原因。
- `package_resolution.csv`：资产类型、资产标识、来源卡、legacy 套餐、目标套餐、目标状态、优先级、决策、原因。
- `summary.txt`：按资产类型、状态、错误类型汇总。

**理由**：迁移脚本是一次性高风险操作，执行前必须能从产物直接解释每个资产和套餐的处理结果。

**替代方案**：只写 `errors.csv` / `warnings.csv`。否决原因：只能看到异常，无法证明正常路径是否符合业务预期。

### 决策 5：关键缺失项阻断，不再静默降级

**选择**：以下情况必须写入 `errors.csv` 并跳过对应资产或套餐：
- 目标店铺码不存在或为空且规则要求分配。
- 设备 `current_slot` / `package_source_slot` 不在 1-4 或对应槽位无卡。
- legacy 套餐没有 `target_package_id`。
- 目标套餐不存在、已删除或不是正式套餐。
- 同一资产产生多个 active 主套餐且无法按规则裁决。

**理由**：静默进入平台库存或漏迁套餐会让后续人工排查成本远高于生成期阻断。

## Risks / Trade-offs

- **[风险] 新配置结构与旧 `mapping.yaml` 不兼容** → 提供清晰错误和示例，必要时保留旧字段读取但输出升级提示。
- **[风险] 奇成套餐状态字段与真实生效窗口不一致** → 审核 CSV 同时输出奇成原始 `status/type/start_date/expire_date`，便于人工确认。
- **[风险] 待生效套餐优先级排序错误会影响自动激活顺序** → 按 `start_date ASC, expire_date ASC, legacy_life_id ASC` 生成稳定排序，并在 `package_resolution.csv` 输出 priority。
- **[风险] 批量默认店铺误配会影响大量资产** → `ownership_resolution.csv` 必须在执行 SQL 前人工抽查；summary 输出目标店铺分布。
- **[Trade-off] 不做 UI 化配置** → 先保持脚本和 YAML，满足迁移批次的速度和可审查性，后续如迁移频率升高再考虑管理界面。

## Migration Plan

1. 扩展 `mapping.yaml.example` 和 `resources/README.md`，明确批量规则、槽位字段和审核流程。
2. 增强配置加载器，新增结构化配置对象和校验错误。
3. 扩展 CSV 加载器，支持 `current_slot` / `package_source_slot` 列；未填时从 `ownership_rules.device` 取默认值。
4. 扩展奇成查询，读取完整正式套餐生命周期。
5. 重构 SQL 构造逻辑，拆分独立卡和设备套餐生成。
6. 新增审核 CSV 与阻断错误输出。
7. 用当前 120 台设备样例和新增小样例手动生成 SQL，核对 120 台归属、4 类套餐迁移问题和待生效队列。

**回滚**：本变更只影响 SQL 生成脚本。若生成产物不符合预期，不执行输出 SQL 即可；已生成 SQL 可删除后用旧分支重新生成。

## Open Questions

- 是否所有迁移批次默认都只有一个目标店铺，还是需要支持按资源文件分组指定多个默认店铺？
- 待生效套餐 `activated_at` 是否保留奇成 `start_date`，还是写 `NULL` 等待新系统激活时回填？
- 同一来源卡存在多个奇成 `status=1` 正式套餐时，是否按时间窗口裁决一个 active，其余转 pending，还是直接阻断人工处理？
