# iot-card-import-task Specification

## Purpose
TBD - created by archiving change iot-card-standalone-management. Update Purpose after archive.
## Requirements
### Requirement: 导入任务实体定义

系统 SHALL 定义 IoT 卡导入任务(IotCardImportTask)实体,用于跟踪 IoT 卡批量导入的进度和结果。

**实体字段**:

**任务信息**:
- `id`: 任务 ID(主键,BIGINT)
- `task_no`: 任务编号(VARCHAR(50),唯一,格式: IMP-YYYYMMDD-XXXXXX)
- `status`: 任务状态(INT,1-待处理 2-处理中 3-已完成 4-失败)

**导入参数**:
- `carrier_id`: 运营商 ID(BIGINT,必填)
- `carrier_type`: 运营商类型(VARCHAR(10),CMCC/CUCC/CTCC/CBN)
- `batch_no`: 批次号(VARCHAR(100),可选)
- `file_name`: 原始文件名(VARCHAR(255),可选)

**待导入数据**:
- `card_list`: 待导入卡列表(JSONB,结构: [{iccid, msisdn}],替代原 iccid_list)

**进度统计**:
- `total_count`: 总数(INT,CSV 文件总行数)
- `success_count`: 成功数(INT,成功导入的卡数量)
- `skip_count`: 跳过数(INT,因重复等原因跳过的数量)
- `fail_count`: 失败数(INT,因格式错误等原因失败的数量)

**结果详情**:
- `skipped_items`: 跳过记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])
- `failed_items`: 失败记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])

**时间和错误**:
- `started_at`: 开始处理时间(TIMESTAMP,可空)
- `completed_at`: 完成时间(TIMESTAMP,可空)
- `error_message`: 任务级错误信息(TEXT,可空,如文件解析失败等)

**系统字段**:
- `shop_id`: 店铺 ID(BIGINT,可空,记录发起导入的店铺)
- `created_at`: 创建时间(TIMESTAMP,自动填充)
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
- `creator`: 创建人 ID(BIGINT)
- `updater`: 更新人 ID(BIGINT)

#### Scenario: 创建导入任务

- **GIVEN** 管理员上传包含 ICCID 和 MSISDN 两列的 CSV 文件
- **WHEN** 系统解析 CSV 并创建导入任务
- **THEN** 系统创建导入任务记录,`card_list` 包含 [{iccid, msisdn}] 结构,`status` 为 1(待处理)

---

### Requirement: 导入任务状态流转

系统 SHALL 管理导入任务的状态流转,确保状态变更符合业务规则。

**状态定义**:
- **1-待处理**: 任务已创建,等待 Worker 处理
- **2-处理中**: Worker 正在处理导入
- **3-已完成**: 导入处理完成(可能有部分失败)
- **4-失败**: 任务级别错误,导入中断

**状态流转规则**:
- 待处理(1) → 处理中(2): Worker 开始处理
- 处理中(2) → 已完成(3): 处理完成
- 处理中(2) → 失败(4): 发生严重错误
- 待处理(1) → 失败(4): 文件验证失败等

#### Scenario: 正常状态流转

- **WHEN** 导入任务经历完整生命周期
- **THEN** 状态依次变更: 待处理(1) → 处理中(2) → 已完成(3)

#### Scenario: 异常状态流转

- **WHEN** 导入任务处理过程中发生严重错误
- **THEN** 状态变更: 待处理(1) → 处理中(2) → 失败(4)

---

### Requirement: 导入任务创建权限控制

系统 SHALL 仅允许超级管理员与平台用户创建 IoT 卡导入任务。

#### Scenario: 平台用户创建导入任务
- **WHEN** 平台用户请求创建导入任务
- **THEN** 系统创建导入任务并返回任务信息

#### Scenario: 非平台用户创建导入任务被拒绝
- **WHEN** 非平台用户（代理账号/企业账号等）请求创建导入任务
- **THEN** 系统返回 403（Forbidden），并返回统一错误码 `CodeForbidden`

---

### Requirement: 导入任务列表查询

系统 SHALL 支持查询导入任务列表,用于管理和监控导入任务。

**查询条件**:
- 任务状态(status): 可选,1-待处理 2-处理中 3-已完成 4-失败
- 运营商 ID(carrier_id): 可选
- 批次号(batch_no): 可选,模糊匹配
- 创建时间范围: 可选

**分页**:
- 默认每页 20 条,最大每页 100 条
- 默认按创建时间倒序排列

**权限**:
- 仅超级管理员/平台用户可查询导入任务列表

#### Scenario: 查询所有导入任务

- **WHEN** 平台管理员查询导入任务列表
- **THEN** 系统返回导入任务列表,包含任务编号、状态、运营商、总数、成功数、跳过数、失败数、创建时间

#### Scenario: 按状态筛选导入任务

- **WHEN** 平台管理员查询状态为 2(处理中) 的导入任务
- **THEN** 系统返回所有正在处理的导入任务列表

#### Scenario: 非平台用户查询导入任务列表被拒绝

- **WHEN** 非平台用户（代理账号/企业账号等）查询导入任务列表
- **THEN** 系统返回 403（Forbidden），并返回统一错误码 `CodeForbidden`

---

### Requirement: 导入任务详情查询

系统 SHALL 支持查询单个导入任务的详细信息,包括跳过/失败记录详情。

**详情信息**:
- 任务基本信息: 任务编号、状态、运营商、批次号、文件名
- 进度统计: 总数、成功数、跳过数、失败数
- 时间信息: 创建时间、开始时间、完成时间
- 跳过记录详情: 行号、ICCID、原因
- 失败记录详情: 行号、ICCID、原因
- 错误信息: 任务级错误(如有)

**权限**:
- 仅超级管理员/平台用户可查询导入任务详情

#### Scenario: 查询导入任务详情

- **WHEN** 平台管理员查询导入任务(ID 为 1)的详情
- **THEN** 系统返回任务完整信息,包括跳过和失败记录的详细列表

#### Scenario: 非平台用户查询导入任务详情被拒绝

- **WHEN** 非平台用户（代理账号/企业账号等）查询导入任务详情
- **THEN** 系统返回 403（Forbidden），并返回统一错误码 `CodeForbidden`

#### Scenario: 查询导入任务的跳过记录

- **WHEN** 管理员查询导入任务(ID 为 1)的跳过记录
- **THEN** 系统返回跳过记录列表,每条包含: 行号(line)、ICCID、原因(如"ICCID 已存在")

#### Scenario: 查询导入任务的失败记录

- **WHEN** 管理员查询导入任务(ID 为 1)的失败记录
- **THEN** 系统返回失败记录列表,每条包含: 行号(line)、ICCID、原因(如"电信 ICCID 必须为 19 位")

---

### Requirement: 导入任务数据校验

系统 SHALL 对导入任务数据进行校验,确保数据完整性和一致性。

**校验规则**:
- 任务编号(task_no): 必填,系统自动生成,格式 IMP-YYYYMMDD-XXXXXX,唯一
- 任务状态(status): 必填,枚举值 1(待处理) | 2(处理中) | 3(已完成) | 4(失败)
- 运营商 ID(carrier_id): 必填,必须是有效的运营商 ID
- 总数(total_count): 必填,≥ 0
- 成功数(success_count): 必填,≥ 0,≤ total_count
- 跳过数(skip_count): 必填,≥ 0,≤ total_count
- 失败数(fail_count): 必填,≥ 0,≤ total_count
- 数量一致性: success_count + skip_count + fail_count ≤ total_count

#### Scenario: 创建任务时运营商 ID 无效

- **WHEN** 创建导入任务时 carrier_id 不存在
- **THEN** 系统拒绝创建,返回错误信息"运营商 ID 无效"

#### Scenario: 更新任务时数量不一致

- **WHEN** 更新导入任务时 success_count + skip_count + fail_count > total_count
- **THEN** 系统拒绝更新,返回错误信息"统计数量不一致"

### Requirement: Excel 文件格式规范

系统 SHALL 要求 Excel 文件必须包含 ICCID、MSISDN、虚拟号三列，按固定列位置读取，表头行内容不影响解析结果。

**文件格式要求**:
- **文件格式**: 仅支持 `.xlsx` (Excel 2007+)
- **Sheet**: 读取第一个sheet，或优先读取名为"导入数据"的sheet
- **表头行**: 第1行固定为表头，永远跳过，内容不限（中文、英文均可）
- **列位置（固定，不可变）**:
  - 第1列（索引0）: ICCID
  - 第2列（索引1）: MSISDN
  - 第3列（索引2）: 虚拟号（必填）
- **列格式**: 应设置为文本格式（避免长数字被转为科学记数法）

**解析规则**:
- 按列索引取值，不识别列名，第一行永远跳过
- 自动去除单元格首尾空格
- 若 ICCID、MSISDN、virtual_no 三列皆为空，视为空行跳过，不计入 total，不计入失败
- ICCID 为空（但其他列非空）的行记录为失败，失败原因为"ICCID 不能为空"
- MSISDN 为空（但其他列非空）的行记录为失败，失败原因为"MSISDN 不能为空"
- virtual_no（第3列）为空的行记录为失败，失败原因为"虚拟号(virtual_no)不能为空"

**virtual_no 导入规则**:
- virtual_no 为必填，为空则该行记录为失败
- virtual_no 全局唯一（跨卡和设备），重复则失败，原因为"虚拟号已被占用: <值>"

#### Scenario: 正常导入（ICCID 和 VirtualNo 均有值）

- **WHEN** Excel 中某行第1列 ICCID="898600XXXXX"，第3列 virtual_no="CARD-001"，第2列 msisdn="13800000001"
- **THEN** 导入成功，卡记录写入数据库，VirtualNo 和 ICCID 同步注册到 `tb_asset_identifier`

#### Scenario: 中文表头正常导入

- **GIVEN** Excel 文件第1行表头为 `ICCID | 接入号 | 虚拟号`（任意中文或英文内容）
- **WHEN** 系统解析该 Excel 文件
- **THEN** 系统跳过第1行，从第2行开始按列位置解析数据

#### Scenario: VirtualNo 为空的行被拒绝

- **WHEN** Excel 中某行第3列为空
- **THEN** 该行计入失败，失败原因为"虚拟号(virtual_no)不能为空"
- **THEN** 其他合法行继续导入，不因此行中断

#### Scenario: VirtualNo 重复被拒绝

- **WHEN** Excel 中某行第3列的值与已有卡/设备的 VirtualNo 重复（跨表）
- **THEN** 该行计入失败，原因为"虚拟号已被占用: <值>"

#### Scenario: 三列皆空时跳过不计入 total

- **WHEN** Excel 中某行 ICCID、MSISDN、virtual_no 三列均为空
- **THEN** 该行视为空行跳过，不计入 total，不计入失败

#### Scenario: 导入任务完成后的结果报告

- **WHEN** 导入任务处理完毕
- **THEN** 结果包含：`success_count`、`fail_count`、`skip_count`、失败明细列表（含行号、原因）
- **THEN** VirtualNo 为空的失败行在明细中明确体现行号和原因

#### Scenario: 拒绝非Excel格式文件

- **GIVEN** 上传文件扩展名为 .csv
- **WHEN** 系统尝试解析该文件
- **THEN** 系统返回错误"不支持的文件格式 .csv，请上传Excel文件(.xlsx)"

#### Scenario: MSISDN 为空的行记录失败

- **WHEN** Excel 中某行第2列为空
- **THEN** 该行标记为失败，原因为"MSISDN 不能为空"

#### Scenario: 长数字无损解析

- **GIVEN** Excel 文件中第1列设置为文本格式，包含 20 位数字 "89860012345678901234"
- **WHEN** 系统解析该 Excel 文件
- **THEN** ICCID 完整保留为 "89860012345678901234"，无精度损失，无科学记数法

---

### Requirement: 导入时填充 MSISDN 字段

系统 SHALL 在创建 IoT 卡记录时填充 MSISDN 字段。

**处理规则**:
- 从 `card_list` 中获取 ICCID 和 MSISDN
- 创建 `IotCard` 记录时同时设置 `iccid` 和 `msisdn` 字段

#### Scenario: 创建卡记录时填充 MSISDN

- **GIVEN** 导入任务包含卡数据 [{iccid: "898600...", msisdn: "13800000001"}]
- **WHEN** Worker 处理导入任务创建卡记录
- **THEN** 创建的 `IotCard` 记录 `iccid` 为 "898600...",`msisdn` 为 "13800000001"

---

### Requirement: 导入物联网卡时记录运营商信息

系统 SHALL 在导入物联网卡时，将运营商的 carrier_type 和 carrier_name 作为冗余字段存储到 IotCard 记录中。这些字段在导入时从 Carrier 表查询并写入，后续不再依赖 Carrier 表。

#### Scenario: 导入时填充冗余字段
- **WHEN** 系统处理物联网卡导入任务
- **THEN** 系统根据 carrier_id 查询 Carrier 表，将 carrier_type 和 carrier_name 写入每条 IotCard 记录

#### Scenario: Carrier 不存在
- **WHEN** 导入任务指定的 carrier_id 对应的 Carrier 不存在或已删除
- **THEN** 系统拒绝导入，返回错误"运营商不存在"

---

### Requirement: 导入任务记录运营商名称

系统 SHALL 在创建导入任务时，将 carrier_name 作为冗余字段存储到 IotCardImportTask 记录中（已有 carrier_type）。

#### Scenario: 创建导入任务时填充 carrier_name
- **WHEN** 管理员创建物联网卡导入任务
- **THEN** 系统根据 carrier_id 查询 Carrier 表，将 carrier_name 写入导入任务记录

---

### Requirement: 导入批次支持卡业务类型

系统 SHALL 在 IoT 卡导入请求中支持 `card_category` 参数，以批次为单位指定导入卡的业务类型，默认为普通卡。

**请求字段**:
- `card_category`: 卡业务类型（枚举：`normal` / `industry`，可选，默认 `normal`）

**任务字段**:
- `IotCardImportTask` 新增 `card_category` 字段（VARCHAR(20)，默认 `normal`）

**导入行为**:
- 导入任务中所有卡统一使用 `card_category` 的值，不支持同一批次混用

#### Scenario: 不传 card_category 时默认为普通卡

- **WHEN** 导入请求未包含 `card_category` 字段
- **THEN** 导入的所有卡 `card_category` 为 `normal`

#### Scenario: 指定行业卡导入

- **WHEN** 导入请求中 `card_category` = `industry`
- **THEN** 该批次导入的所有卡 `card_category` 均为 `industry`

#### Scenario: 传入无效 card_category

- **WHEN** 导入请求中 `card_category` = `unknown`（非枚举值）
- **THEN** 系统返回参数校验错误，提示卡业务类型无效

---

### Requirement: 导入批次支持实名策略配置

系统 SHALL 在 IoT 卡导入请求（`ImportIotCardRequest`）中支持 `realname_policy` 参数，以批次为单位指定导入卡的实名策略，默认为 `none`。

**请求字段新增**：
- `realname_policy`：实名认证策略（枚举：`none` / `before_order` / `after_order`，可选，默认 `none`）

**任务字段新增**：
- `IotCardImportTask` 新增 `realname_policy` 字段（VARCHAR(20) NOT NULL DEFAULT 'none'）

**导入行为**：
- 该批次导入的所有卡统一使用 `realname_policy` 的值写入 `IotCard.realname_policy`，不支持同一批次混用
- 导入任务记录该字段用于审计

#### Scenario: 不传 realname_policy 时默认为 none
- **WHEN** 导入请求未包含 `realname_policy` 字段
- **THEN** 导入的所有卡 `realname_policy` 为 `none`，导入任务 `realname_policy` 为 `none`

#### Scenario: 指定 before_order 导入
- **WHEN** 导入请求中 `realname_policy` = `before_order`
- **THEN** 该批次导入的所有卡 `realname_policy` 均为 `before_order`

#### Scenario: 传入无效 realname_policy
- **WHEN** 导入请求中 `realname_policy` = `unknown`（非枚举值）
- **THEN** 系统返回参数校验错误，提示实名策略值无效

#### Scenario: 导入任务响应返回 realname_policy
- **WHEN** 管理员查询导入任务详情或列表
- **THEN** 响应中包含 `realname_policy` 字段，与创建时传入值一致

