## Context

**设备**：`tb_device.virtual_no` 已是 `NOT NULL + UNIQUE`（数据库层已强制）。但 `pkg/utils/excel.go` 中设备行解析遇到空 VirtualNo 会 `continue`（跳过该行），导入任务报告跳过数量但不报告失败原因，用户无法知道为什么某些行没被导入。

**IoT 卡**：`tb_iot_card.virtual_no` 当前为 nullable，唯一索引条件是 `WHERE deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''`（允许多条 NULL 值）。导入时空 VirtualNo 的卡直接进库，不做任何处理。

两者均需要把"安静跳过/允许空值"改为"明确报错"，并在数据库层为 IoT 卡补上 NOT NULL 约束。

## Goals / Non-Goals

**Goals:**
- 导入时 VirtualNo 为空 → 报告为失败行，包含行号和原因
- `tb_iot_card.virtual_no` 加 NOT NULL 约束
- 更新 Excel 解析层、任务处理层、模型层

**Non-Goals:**
- 为现有 NULL 记录自动生成 VirtualNo（测试阶段直接清库）
- IoT 卡导入的其他字段校验（不在本提案范围内）
- VirtualNo 格式校验（长度/字符集约束，留给未来）

## Decisions

### 决策 1：在 Excel 解析层（pkg/utils/excel.go）拦截空值，不在任务层

**选择**：在 `parseCardRows()` 和 `parseDeviceRows()` 中，当 VirtualNo 为空时，将该行加入解析错误列表（ParseError），而非 `continue` 跳过

**备选方案**：在任务处理层（`iot_card_import.go` / `device_import.go`）拦截。问题：任务层已有处理批次逻辑，较复杂；解析层更早发现问题，更符合"fail fast"原则

**理由**：越早发现越好；解析层返回 ParseErrors 与当前批量验证逻辑（ICCID 格式校验已在此处）风格一致

### 决策 2：IoT 卡数据库层迁移分两步

1. **先清库**（测试阶段手动执行）：`DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = ''`
2. **再加约束**（迁移文件）：
   - `ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL`
   - 重建唯一索引，去掉 `WHERE virtual_no IS NOT NULL AND virtual_no <> ''` 条件，改为无条件唯一

**理由**：先清数据再加约束，迁移不会失败；两步操作在同一迁移文件中完成，原子执行（PostgreSQL 支持事务内 DDL）

### 决策 3：设备导入不需要数据库迁移，只改导入行为

设备的 VirtualNo 数据库层已是 NOT NULL，不需要迁移。只需把 `excel.go` 中 `if row.VirtualNo == "" { continue }` 改为加入失败列表即可。

## Risks / Trade-offs

| 风险 | 缓解措施 |
|------|---------|
| **现有使用方的 Excel 模板无 VirtualNo 列** | 属于 BREAKING 变更，提前通知；导入失败信息明确说明"VirtualNo 为必填列" |
| **迁移前 IoT 卡存在 NULL 记录导致 ALTER 失败** | 迁移文件中先执行 DELETE 清理（适用测试环境），再 ALTER；生产环境需手动确认数据干净后执行 |
| **IoT 卡在注册表（提案一）中未注册 VirtualNo** | 本提案独立于提案一；若提案一先完成，IoT 卡导入时需同步注册 VirtualNo 到注册表（在提案一 task 6.2 中已覆盖） |

## Migration Plan

1. 清理测试库中 `virtual_no IS NULL` 的 IoT 卡记录
2. 执行数据库迁移：`tb_iot_card.virtual_no` 加 NOT NULL，重建唯一索引
3. 更新 `pkg/utils/excel.go`（解析层）
4. 更新 `internal/task/iot_card_import.go`（任务层，去除残余跳过逻辑）
5. 更新 `internal/task/device_import.go`（任务层，同上）
6. 更新 `internal/model/iot_card.go`（GORM tag）
7. 更新文档 `docs/excel-import-frontend-guide.md`

## Open Questions

- 无
