## 1. 数据库迁移：IoT 卡 VirtualNo NOT NULL

- [x] 1.1 手动清理测试库：执行 `DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = ''`，确认影响行数为 0 后继续（或直接清库）
- [x] 1.2 创建迁移文件（如 `000XXX_iot_card_virtual_no_not_null.up.sql`）：
  ```sql
  ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL;
  DROP INDEX IF EXISTS idx_iot_card_virtual_no;
  CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL;
  ```
- [x] 1.3 创建对应 down 迁移文件（回滚为 nullable）
- [x] 1.4 执行迁移，验证约束生效（尝试插入 virtual_no=NULL 的记录应失败）

## 2. 模型层：更新 IotCard GORM Tag

- [x] 2.1 更新 `internal/model/iot_card.go` 中 `VirtualNo` 字段的 GORM tag：
  - 去掉 `omitempty`（JSON tag）
  - 将唯一索引条件从 `where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''` 简化为 `where:deleted_at IS NULL`
  - 更新字段注释：从"虚拟号(可空，全局唯一)"改为"虚拟号(必填，全局唯一)"

## 3. Excel 解析层：IoT 卡 VirtualNo 必填校验

- [x] 3.1 更新 `pkg/utils/excel.go` 的 `parseCardRows()` 函数（或等效解析逻辑）：
  - 当 `virtualNo == ""` 时，不再跳过，而是将该行追加到解析错误列表：`ParseErrors = append(ParseErrors, ParseError{Line: lineNum, Reason: "虚拟号(virtual_no)不能为空"})`
  - 若 Excel 文件中完全没有 VirtualNo 列（`virtualNoCol == -1`），在函数开头直接返回错误：`return nil, errors.New(errors.CodeInvalidParam, "Excel 文件缺少 virtual_no 列，请使用最新模板")`
  - 确认函数返回值能携带 ParseErrors（若当前无此结构，需扩展返回类型）

## 4. Excel 解析层：设备 VirtualNo 改为失败行

- [x] 4.1 更新 `pkg/utils/excel.go` 的设备行解析逻辑：
  - 找到 `if row.VirtualNo == "" { continue }` 代码（当前静默跳过）
  - 改为将该行追加到失败列表：`failedRows = append(failedRows, FailedRow{Line: row.Line, Reason: "设备虚拟号(virtual_no)不能为空"})`
  - 确认 `skip_count` 不再计入此类行，改计入 `fail_count`

## 5. 任务处理层：IoT 卡导入移除残余跳过逻辑

- [x] 5.1 检查 `internal/task/iot_card_import.go` 中所有 `if card.VirtualNo != ""` 条件：
  - 唯一性检查处（当前只对非空 VirtualNo 检查）：移除条件，强制检查所有行的 VirtualNo
  - 批内去重处：同样移除条件
  - 若 VirtualNo 为空的行此时能到达任务处理层（不应发生，因解析层已拦截），也在此处记录失败并跳过

## 6. 任务处理层：设备导入统计修正

- [x] 6.1 检查 `internal/task/device_import.go` 中处理空 VirtualNo 行的逻辑：
  - 确认 Excel 解析层改动后，空 VirtualNo 行已以 `FailedRow` 形式传入，不再需要任务层的额外 `continue` 跳过
  - 验证 `result.failCount` 正确累加，`result.skipCount` 不受影响

## 7. 文档更新

- [x] 7.1 更新 `docs/excel-import-frontend-guide.md` 及路由描述（`internal/routes/iot_card.go`, `internal/routes/device.go`）：
  - IoT 卡导入模板字段表：`virtual_no` 列的"必填"列从"否"改为"是"
  - 新增说明："`virtual_no` 为必填列，留空将导致该行导入失败"
  - 设备导入模板字段表：`virtual_no` 列同样标注"必填"，并说明"留空不再跳过，将记录为失败行"

## 8. 验证

- [x] 8.1 构建验证：`go build ./...` 无编译错误（本次修改包 build OK；bootstrap 已有预存在错误与本次无关）
- [x] 8.2 LSP 诊断：对所有修改文件运行 lsp_diagnostics，无错误/警告
- [ ] 8.3 IoT 卡导入验证（使用 Hurl 或 curl）：
  - 上传含完整 VirtualNo 的 Excel：导入成功，success_count 正确
  - 上传含空 VirtualNo 行的 Excel：该行 fail_count+1，reason 为"虚拟号(virtual_no)不能为空"，其他行正常导入
  - 上传无 VirtualNo 列的 Excel：全部失败，返回"Excel 文件缺少 virtual_no 列"
  - 上传重复 VirtualNo 的 Excel：该行 fail_count+1，reason 为"虚拟号已被占用: <值>"
- [ ] 8.4 设备导入验证：
  - 上传含空 VirtualNo 行的 Excel：该行计入 fail_count（而非 skip_count），reason 为"设备虚拟号(virtual_no)不能为空"
- [x] 8.5 数据库约束验证（PostgreSQL MCP）：
  - `virtual_no` 列 is_nullable=NO 已验证；新索引 `CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL` 已验证
