diff --git a/openspec/changes/archive/2026-04-09-excel-import-refactor/.openspec.yaml b/openspec/changes/archive/2026-04-09-excel-import-refactor/.openspec.yaml new file mode 100644 index 0000000..98d7681 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-excel-import-refactor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-09 diff --git a/openspec/changes/archive/2026-04-09-excel-import-refactor/design.md b/openspec/changes/archive/2026-04-09-excel-import-refactor/design.md new file mode 100644 index 0000000..e41ae86 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-excel-import-refactor/design.md @@ -0,0 +1,57 @@ +## Context + +当前 IoT 卡和设备的 Excel 导入依赖列名识别逻辑(`findCardColumns` / `buildDeviceColumnIndex`)。业务侧要求改用中文表头,但设备导入的大多数列名只支持英文,导致中文表头文件解析全部失败。 + +同时,IoT 卡导入时 `card_category` 写死为 `normal`,无法在导入批次中指定行业卡。 + +## Goals / Non-Goals + +**Goals:** +- Excel 导入不依赖表头内容,按固定列位置读取,支持任意语言表头 +- 锁定 IoT 卡和设备导入的列顺序为业务确认版本 +- IoT 卡导入支持批次级别的 `card_category`(行业卡/普通卡) + +**Non-Goals:** +- 不支持列顺序自定义(固定列位置就是合约) +- 不支持每行独立设置 `card_category`(批次统一即可) +- 不修改导入的其他业务逻辑(校验规则、权限等不变) + +## Decisions + +### 决策1:按列位置读取,完全移除列名识别 + +**选择**: 直接按列索引取值,第一行永远跳过(表头行)。 + +**备选方案**: 在现有映射中添加中文列名别名。 + +**理由**: 中文别名方案需要为每个字段定义多套名称(中文/英文/变体),维护成本高且仍然脆弱(业务随时可能换叫法)。按位置读取更简单,也更明确——模板即契约,列顺序固定。 + +### 决策2:card_category 放在请求参数而非 Excel 列 + +**选择**: 在 `ImportIotCardRequest` 和 `IotCardImportTask` 中加 `card_category` 字段,不在 Excel 里加列。 + +**理由**: 业务现实是一批卡要么全是行业卡,要么全是普通卡,不会混批。前端用下拉选择比 Excel 里填枚举值更直观,也不影响 Excel 列顺序。 + +### 决策3:card_category 默认值为 normal + +**选择**: 不传时默认 `normal`(普通卡)。 + +**理由**: 存量接口调用不需要修改,向下兼容。 + +## Risks / Trade-offs + +- **[风险] 模板纪律性**:按位置读取意味着列顺序必须严格匹配。用户如果自行调整 Excel 列顺序,数据会静默读错(没有报错提示)。→ 缓解:提供官方模板下载,文档中明确说明列顺序不可更改。 +- **[权衡] 丢失了列名容错性**:现有逻辑允许列顺序任意,改后不再支持。接受此代价,换取代码简单和中文支持。 + +## Migration Plan + +1. 更新 `pkg/utils/excel.go`,替换解析逻辑(按列位置取值,删除 `findCardColumns` / `buildDeviceColumnIndex`) +2. 为 `tb_iot_card_import_task` 创建迁移,新增 `card_category VARCHAR(20) NOT NULL DEFAULT 'normal'` 列 +3. 更新 DTO(`ImportIotCardRequest` 加 `card_category`)、model(`IotCardImportTask` 加 `CardCategory`)、service(`CreateImportTask` 将请求字段写入 task)、task handler(创建 IoT 卡时使用 `task.CardCategory` 替代常量) +4. **同步更新导入模板文件**:IoT 卡、设备的 `.xlsx` 模板列顺序需与新实现严格对齐;如模板由前端维护,需在变更交接时通知前端同步 + +**说明**:卡主表 `tb_iot_card` 早有 `card_category` 字段,本次迁移仅为批次级选择提供"载体列",让 Worker 从 DB 任务行读取批次类型,而非从 Asynq 载荷传递(保持现有"载荷只带 TaskID"的设计一致性)。 + +## Open Questions + +(无) diff --git a/openspec/changes/archive/2026-04-09-excel-import-refactor/proposal.md b/openspec/changes/archive/2026-04-09-excel-import-refactor/proposal.md new file mode 100644 index 0000000..31722a4 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-excel-import-refactor/proposal.md @@ -0,0 +1,31 @@ +## Why + +业务侧要求 Excel 导入模板使用中文表头,但现有实现依赖英文列名匹配,导致中文表头的文件无法正确导入。同时,IoT 卡导入时卡业务类型(行业卡/普通卡)写死为普通卡,无法在导入批次中指定行业卡。 + +## What Changes + +- **Excel 按列位置读取**:移除基于列名识别的逻辑,改为按固定列索引取值,第一行固定作为表头跳过,表头内容不再影响解析结果 +- **删除冗余函数**:删除 `findCardColumns()` 和 `buildDeviceColumnIndex()` 两个函数 +- **设备列顺序调整**:设备导入列顺序从原来的排列调整为业务确认的顺序(IMEI 提前至第5列) +- **IoT 卡导入支持卡业务类型**:`ImportIotCardRequest` 新增 `card_category` 字段(批次级别),导入时按请求参数设置,默认为 `normal` + +## Capabilities + +### New Capabilities + +(无新能力) + +### Modified Capabilities + +- `iot-card-import-task`:导入请求新增 `card_category` 字段,任务模型同步新增该字段,导入时按批次统一设置卡业务类型 +- `device-import`:移除列名识别逻辑,改为按固定列位置读取,列顺序调整为业务确认顺序 + +## Impact + +- `pkg/utils/excel.go`:核心解析逻辑重写(按列位置取值),删除 `findCardColumns` / `buildDeviceColumnIndex` 两个函数 +- `internal/model/dto/iot_card_dto.go`:`ImportIotCardRequest` 新增 `CardCategory` 字段(带 `oneof=normal industry` 校验),`ImportTaskResponse` 新增 `card_category` 返回 +- `internal/model/iot_card_import_task.go`:`IotCardImportTask` 新增 `CardCategory` 字段 +- `migrations/`:新增迁移为 `tb_iot_card_import_task` 增加 `card_category` 列(注:卡主表 `tb_iot_card` 早已有该字段,本次只补"批次级载体列") +- `internal/service/iot_card_import/service.go`:`CreateImportTask` 将 `req.CardCategory` 写入 task,空串兜底为 `normal` +- `internal/task/iot_card_import.go`:使用 `task.CardCategory` 替代写死的 `CardCategoryNormal` +- **模板文件**:需同步更新 IoT 卡、设备导入模板 `.xlsx`,确保列顺序与代码一致(高风险:列顺序不匹配会导致数据静默错位) diff --git a/openspec/changes/archive/2026-04-09-excel-import-refactor/specs/device-import/spec.md b/openspec/changes/archive/2026-04-09-excel-import-refactor/specs/device-import/spec.md new file mode 100644 index 0000000..f2d0e15 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-excel-import-refactor/specs/device-import/spec.md @@ -0,0 +1,101 @@ +## MODIFIED Requirements + +### Requirement: 设备批量导入 + +系统 SHALL 提供设备批量导入功能,通过 Excel 文件导入设备并自动绑定卡,按固定列位置读取,表头行内容不影响解析结果,仅平台用户可操作。 + +**API 端点**: `POST /api/admin/devices/import` + +**请求参数**: +- `batch_no`: 批次号(必填) +- `file_key`: 对象存储文件路径(必填,通过 /storage/upload-url 获取) + +**Excel 格式**: +- **文件格式**: 仅支持 `.xlsx` (Excel 2007+) +- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet +- **表头行**: 第1行固定为表头,永远跳过,内容不限(中文、英文均可) +- **列位置(固定,不可变)**: + ``` + 第1列(索引0): 虚拟号(必填,全局唯一) + 第2列(索引1): 设备名称(可选) + 第3列(索引2): 设备型号(可选) + 第4列(索引3): 设备类型(可选) + 第5列(索引4): IMEI(可选) + 第6列(索引5): 制造商(可选) + 第7列(索引6): 最大SIM槽数(可选,默认4,范围1-4) + 第8列(索引7): 卡1 ICCID(可选) + 第9列(索引8): 卡2 ICCID(可选) + 第10列(索引9): 卡3 ICCID(可选) + 第11列(索引10): 卡4 ICCID(可选) + ``` +- **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法) + +**失败原因文本**: +- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"` +- MaxSimSlots 越界:`"最大SIM槽数必须在1-4之间"` + +**导入规则**: +- 按列索引取值,不识别列名,第一行永远跳过 +- 若整行目标列皆为空,视为空行跳过,不计入 total,不计入失败 +- MaxSimSlots 为空或 0 回填默认值 4;非空且不在 [1,4] 记录为失败 +- 导入的设备 shop_id = NULL(平台库存) +- 导入的设备 status = 1(在库) +- 设备号重复则该行跳过 +- ICCID 必须已存在于系统中(先导入卡,再导入设备) +- ICCID 不存在则该行失败 +- ICCID 已绑定其他设备则该行失败 +- 导入通过异步任务处理,立即返回任务 ID + +**权限**: 仅平台用户 + +**响应**: +- `task_id`: 导入任务 ID +- `task_no`: 任务编号 +- `message`: 提示信息 + +#### Scenario: 提交设备导入任务 + +- **WHEN** 平台管理员上传 Excel 文件并提交导入请求 +- **THEN** 系统创建导入任务,返回任务 ID,开始异步处理 + +#### Scenario: 中文表头正常导入 + +- **GIVEN** Excel 文件第1行表头为 `虚拟号 | 设备名称 | 设备型号 | 设备类型 | IMEI | 制造商 | 最大SIM槽数 | 卡1 | 卡2 | 卡3 | 卡4` +- **WHEN** 系统解析该 Excel 文件 +- **THEN** 系统跳过第1行,从第2行开始按列位置解析数据 + +#### Scenario: 代理尝试导入设备 + +- **WHEN** 代理用户尝试导入设备 +- **THEN** 系统返回 403 错误,提示"无权限执行此操作" + +#### Scenario: 文件格式错误 + +- **WHEN** 平台管理员上传非 Excel 格式(.xlsx)的文件 +- **THEN** 系统创建任务但处理失败,任务状态为"失败",错误信息为"不支持的文件格式 .csv,请上传Excel文件(.xlsx)" + +#### Scenario: Excel结构错误 + +- **WHEN** 平台管理员上传的Excel文件无工作表或无数据行 +- **THEN** 系统创建任务但处理失败,任务状态为"失败",记录相应错误信息 + +#### Scenario: VirtualNo 为空的行记录为失败 + +- **WHEN** Excel 中某行第1列(虚拟号)为空 +- **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空" +- **THEN** 其他合法行继续导入,不因此行中断 + +#### Scenario: ICCID 不存在 + +- **WHEN** Excel 中某行的 ICCID 在系统中不存在 +- **THEN** 该行导入失败,记录失败原因"ICCID 不存在" + +#### Scenario: ICCID 已绑定其他设备 + +- **WHEN** Excel 中某行的 ICCID 已绑定到其他设备 +- **THEN** 该行导入失败,记录失败原因"ICCID 已绑定其他设备" + +#### Scenario: 设备号重复 + +- **WHEN** Excel 中某行的设备号在系统中已存在 +- **THEN** 该行被跳过,记录跳过原因"设备号已存在" diff --git a/openspec/changes/archive/2026-04-09-excel-import-refactor/specs/iot-card-import-task/spec.md b/openspec/changes/archive/2026-04-09-excel-import-refactor/specs/iot-card-import-task/spec.md new file mode 100644 index 0000000..96003f2 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-excel-import-refactor/specs/iot-card-import-task/spec.md @@ -0,0 +1,98 @@ +## MODIFIED Requirements + +### 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: MSISDN 为空的行记录失败 + +- **WHEN** Excel 中某行第2列为空 +- **THEN** 该行标记为失败,原因为"MSISDN 不能为空" + +#### Scenario: 长数字无损解析 + +- **GIVEN** Excel 文件中第1列设置为文本格式,包含 20 位数字 "89860012345678901234" +- **WHEN** 系统解析该 Excel 文件 +- **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法 + +#### Scenario: 拒绝非Excel格式文件 + +- **GIVEN** 上传文件扩展名为 .csv +- **WHEN** 系统尝试解析该文件 +- **THEN** 系统返回错误"不支持的文件格式 .csv,请上传Excel文件(.xlsx)" + +--- + +## ADDED Requirements + +### 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** 系统返回参数校验错误,提示卡业务类型无效 diff --git a/openspec/changes/archive/2026-04-09-excel-import-refactor/tasks.md b/openspec/changes/archive/2026-04-09-excel-import-refactor/tasks.md new file mode 100644 index 0000000..ef15a07 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-excel-import-refactor/tasks.md @@ -0,0 +1,33 @@ +## 1. Excel 按列位置读取(IoT 卡) + +- [x] 1.1 重写 `parseCardRows`:移除 `findCardColumns` 调用,第1行固定跳过,按 col0=ICCID、col1=MSISDN、col2=VirtualNo 取值 +- [x] 1.2 保留空行跳过逻辑:若 ICCID、MSISDN、VirtualNo 三列皆为空,`continue` 且不计入 total(与原实现一致,避免尾部空行被判失败) +- [x] 1.3 对齐失败原因文案,与 spec 的 Scenario 完全一致("ICCID 不能为空"/"MSISDN 不能为空"/"虚拟号(virtual_no)不能为空",注意半角空格) +- [x] 1.4 删除 `findCardColumns` 函数 + +## 2. Excel 按列位置读取(设备) + +- [x] 2.1 重写 `ParseDeviceExcel`:移除 `buildDeviceColumnIndex` 调用,第1行固定跳过,按确认列顺序取值(col0=虚拟号、col1=设备名称、col2=设备型号、col3=设备类型、col4=IMEI、col5=制造商、col6=最大SIM槽数、col7~10=卡1~4) +- [x] 2.2 保留空行跳过逻辑:若整行目标列皆为空,`continue` 且不计入 total +- [x] 2.3 `MaxSimSlots` 范围校验:读取后若不在 [1,4],计入失败(原因:"最大SIM槽数必须在1-4之间");为空/0 时回填默认值 4 +- [x] 2.4 删除 `buildDeviceColumnIndex` 函数 + +## 3. IoT 卡导入支持卡业务类型 + +- [x] 3.1 `ImportIotCardRequest` DTO 新增 `CardCategory string` 字段,tag:`json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡),默认 normal"` +- [x] 3.2 `IotCardImportTask` model 新增 `CardCategory` 字段,gorm tag:`column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)` +- [x] 3.3 创建数据库迁移 `000111_add_card_category_to_import_task`(up/down 成对),为 `tb_iot_card_import_task` 新增 `card_category VARCHAR(20) NOT NULL DEFAULT 'normal'` 列并加 COMMENT +- [x] 3.4 修改 `internal/service/iot_card_import/service.go CreateImportTask`:构建 `IotCardImportTask` 时写入 `CardCategory`,空串时兜底为 `constants.CardCategoryNormal` +- [x] 3.5 修改 `internal/task/iot_card_import.go` 的 `processBatch`:将写死的 `CardCategory: constants.CardCategoryNormal` 改为 `task.CardCategory` +- [x] 3.6 更新 `ImportTaskResponse` DTO 增加 `card_category` 字段返回,便于列表/详情查询时展示批次类型 + +## 4. 模板文件同步 + +- [x] 4.1 更新仓库内(若有)的 IoT 卡、设备导入模板 `.xlsx`,使列顺序与新实现完全一致;若模板由前端维护,在提案交接时通知前端同步更新下载模板 + +## 5. 手动验证 + +- [ ] 5.1 准备中文表头的 IoT 卡样例 xlsx(含正常行、空行、ICCID/MSISDN/虚拟号为空行、虚拟号重复行),调用 `POST /api/admin/iot-cards/import` 指定 `card_category=industry`,用 PostgreSQL MCP 验证 `tb_iot_card_import_task.card_category='industry'` 且新增卡的 `tb_iot_card.card_category='industry'` +- [ ] 5.2 准备中文表头的设备样例 xlsx(含 IMEI 在第5列),调用 `POST /api/admin/devices/import`,验证成功/失败行数与失败原因文案 +- [ ] 5.3 不传 `card_category` 时验证默认值为 `normal` +- [ ] 5.4 传入 `card_category=invalid` 时验证返回参数校验错误 diff --git a/openspec/specs/device-import/spec.md b/openspec/specs/device-import/spec.md index 704bbb0..425b90b 100644 --- a/openspec/specs/device-import/spec.md +++ b/openspec/specs/device-import/spec.md @@ -5,7 +5,7 @@ TBD - created by archiving change add-device-management. Update Purpose after ar ## Requirements ### Requirement: 设备批量导入 -系统 SHALL 提供设备批量导入功能,通过 Excel 文件导入设备并自动绑定卡,仅平台用户可操作。 +系统 SHALL 提供设备批量导入功能,通过 Excel 文件导入设备并自动绑定卡,按固定列位置读取,表头行内容不影响解析结果,仅平台用户可操作。 **API 端点**: `POST /api/admin/devices/import` @@ -15,37 +15,32 @@ TBD - created by archiving change add-device-management. Update Purpose after ar **Excel 格式**: - **文件格式**: 仅支持 `.xlsx` (Excel 2007+) -- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet -- **表头行**: 第1行,列名如下(顺序可任意): +- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet +- **表头行**: 第1行固定为表头,永远跳过,内容不限(中文、英文均可) +- **列位置(固定,不可变)**: ``` - device_no, device_name, device_model, device_type, - max_sim_slots, manufacturer, virtual_no, iccid_1, iccid_2, iccid_3, iccid_4 + 第1列(索引0): 虚拟号(必填,全局唯一) + 第2列(索引1): 设备名称(可选) + 第3列(索引2): 设备型号(可选) + 第4列(索引3): 设备类型(可选) + 第5列(索引4): IMEI(可选) + 第6列(索引5): 制造商(可选) + 第7列(索引6): 最大SIM槽数(可选,默认4,范围1-4) + 第8列(索引7): 卡1 ICCID(可选) + 第9列(索引8): 卡2 ICCID(可选) + 第10列(索引9): 卡3 ICCID(可选) + 第11列(索引10): 卡4 ICCID(可选) ``` -- **数据行**: 从第2行开始 -- **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法) - -**示例Excel内容**: -``` -| device_no | device_name | device_model | device_type | max_sim_slots | manufacturer | virtual_no | iccid_1 | iccid_2 | iccid_3 | iccid_4 | -|-----------|--------------|--------------|-------------|---------------|--------------|------------|----------------------|----------------------|---------|---------| -| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | VNO-001 | 8986001234567890001 | 8986001234567890002 | | | -| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | VNO-002 | 8986001234567890003 | | | | -``` - -**字段说明**: -- `device_no`: 设备号(必填,唯一) -- `virtual_no`: 虚拟号(**必填**,全局唯一);**[BREAKING]** 为空时不再静默跳过,改为记录为失败行 -- `device_name`: 设备名称(可选) -- `device_model`: 设备型号(可选) -- `device_type`: 设备类型(可选) -- `max_sim_slots`: 最大插槽数(可选,默认 4,范围 1-4) -- `manufacturer`: 制造商(可选) -- `iccid_1` ~ `iccid_4`: 对应插槽 1-4 的 ICCID(可选,空值表示该插槽无卡) +- **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法) **失败原因文本**: - VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"` +- MaxSimSlots 越界:`"最大SIM槽数必须在1-4之间"` **导入规则**: +- 按列索引取值,不识别列名,第一行永远跳过 +- 若整行目标列皆为空,视为空行跳过,不计入 total,不计入失败 +- MaxSimSlots 为空或 0 回填默认值 4;非空且不在 [1,4] 记录为失败 - 导入的设备 shop_id = NULL(平台库存) - 导入的设备 status = 1(在库) - 设备号重复则该行跳过 @@ -66,6 +61,12 @@ TBD - created by archiving change add-device-management. Update Purpose after ar - **WHEN** 平台管理员上传 Excel 文件并提交导入请求 - **THEN** 系统创建导入任务,返回任务 ID,开始异步处理 +#### Scenario: 中文表头正常导入 + +- **GIVEN** Excel 文件第1行表头为 `虚拟号 | 设备名称 | 设备型号 | 设备类型 | IMEI | 制造商 | 最大SIM槽数 | 卡1 | 卡2 | 卡3 | 卡4` +- **WHEN** 系统解析该 Excel 文件 +- **THEN** 系统跳过第1行,从第2行开始按列位置解析数据 + #### Scenario: 代理尝试导入设备 - **WHEN** 代理用户尝试导入设备 @@ -74,13 +75,19 @@ TBD - created by archiving change add-device-management. Update Purpose after ar #### Scenario: 文件格式错误 - **WHEN** 平台管理员上传非 Excel 格式(.xlsx)的文件 -- **THEN** 系统创建任务但处理失败,任务状态为"失败",错误信息为"不支持的文件格式 .csv,请上传Excel文件(.xlsx)" +- **THEN** 系统创建任务但处理失败,任务状态为"失败",错误信息为"不支持的文件格式 .csv,请上传Excel文件(.xlsx)" #### Scenario: Excel结构错误 - **WHEN** 平台管理员上传的Excel文件无工作表或无数据行 - **THEN** 系统创建任务但处理失败,任务状态为"失败",记录相应错误信息 +#### Scenario: VirtualNo 为空的行记录为失败 + +- **WHEN** Excel 中某行第1列(虚拟号)为空 +- **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空" +- **THEN** 其他合法行继续导入,不因此行中断 + --- ### Requirement: 设备导入任务执行 @@ -88,8 +95,9 @@ TBD - created by archiving change add-device-management. Update Purpose after ar 系统 SHALL 异步执行设备导入任务,逐行处理 Excel 数据。 **处理规则**: -- 打开Excel文件,选择第一个sheet(或优先"导入数据"sheet) -- 读取表头行,识别列索引 +- 打开Excel文件,选择第一个sheet(或优先"导入数据"sheet) +- 跳过第1行(表头行),从第2行开始按固定列位置解析数据 +- 若整行目标列皆为空,视为空行跳过,不计入 total - 逐行解析数据 - 对每行数据执行以下校验: 1. 设备号是否已存在(已存在则跳过) @@ -131,11 +139,10 @@ TBD - created by archiving change add-device-management. Update Purpose after ar - **WHEN** Excel 中某行的设备号在系统中已存在 - **THEN** 该行被跳过,记录跳过原因"设备号已存在" -#### Scenario: VirtualNo 为空的行记录为失败(原为跳过) +#### Scenario: VirtualNo 为空的行记录为失败 -- **WHEN** Excel 中某行 virtual_no 列为空或未填写 +- **WHEN** Excel 中某行第1列(虚拟号)为空 - **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空" -- **THEN** 该行不再计入 `skip_count` - **THEN** 其他合法行继续导入,不因此行中断 #### Scenario: 导入任务结果报告包含 VirtualNo 失败原因 diff --git a/openspec/specs/iot-card-import-task/spec.md b/openspec/specs/iot-card-import-task/spec.md index 3c6ccdb..36bb39b 100644 --- a/openspec/specs/iot-card-import-task/spec.md +++ b/openspec/specs/iot-card-import-task/spec.md @@ -192,73 +192,56 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose ### Requirement: Excel 文件格式规范 -系统 SHALL 要求 Excel 文件必须包含 ICCID 和 MSISDN 两列,并支持可选的 `virtual_no` 列。 +系统 SHALL 要求 Excel 文件必须包含 ICCID、MSISDN、虚拟号三列,按固定列位置读取,表头行内容不影响解析结果。 **文件格式要求**: - **文件格式**: 仅支持 `.xlsx` (Excel 2007+) - **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet -- **表头行**: 第1行(可选,但建议包含) -- **表头识别关键字**: - - ICCID 列: iccid/ICCID/卡号/号码 - - MSISDN 列: msisdn/MSISDN/接入号/手机号/电话/号码 - - virtual_no 列(**必填**): virtual_no/VirtualNo/虚拟号/设备号 -- **列数要求**: 至少 3 列(ICCID、MSISDN、virtual_no),virtual_no 为必填列 +- **表头行**: 第1行固定为表头,永远跳过,内容不限(中文、英文均可) +- **列位置(固定,不可变)**: + - 第1列(索引0): ICCID + - 第2列(索引1): MSISDN + - 第3列(索引2): 虚拟号(必填) - **列格式**: 应设置为文本格式(避免长数字被转为科学记数法) **解析规则**: -- 自动检测表头(第1行包含识别关键字则跳过) +- 按列索引取值,不识别列名,第一行永远跳过 - 自动去除单元格首尾空格 -- 跳过空行 -- ICCID 为空的行记录为失败 -- MSISDN 为空的行记录为失败 -- virtual_no 为空的行:**[BREAKING]** 记录为失败行,失败原因为"虚拟号(virtual_no)不能为空"(原行为为跳过该列) -- Excel 中不包含 virtual_no 列时:所有数据行均因 VirtualNo 为空而失败,返回错误提示"Excel 文件缺少 virtual_no 列,请使用最新模板" +- 若 ICCID、MSISDN、virtual_no 三列皆为空,视为空行跳过,不计入 total,不计入失败 +- ICCID 为空(但其他列非空)的行记录为失败,失败原因为"ICCID 不能为空" +- MSISDN 为空(但其他列非空)的行记录为失败,失败原因为"MSISDN 不能为空" +- virtual_no(第3列)为空的行记录为失败,失败原因为"虚拟号(virtual_no)不能为空" **virtual_no 导入规则**: -- virtual_no 为必填列,为空则该行记录为失败 +- virtual_no 为必填,为空则该行记录为失败 - virtual_no 全局唯一(跨卡和设备),重复则失败,原因为"虚拟号已被占用: <值>" -- **批次级唯一性检查**:在执行导入前,先检查整批中所有 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表 - -**示例 Excel 内容**: -``` -| ICCID | MSISDN | 虚拟号 | -|----------------------|-------------|-----------| -| 89860012345678901234 | 13800000001 | CARD-001 | -| 89860012345678901235 | 13800000002 | | -``` #### Scenario: 正常导入(ICCID 和 VirtualNo 均有值) -- **WHEN** Excel 中某行 ICCID="898600XXXXX",virtual_no="CARD-001",msisdn="13800000001" +- **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 中某行 ICCID="898600YYYYY",virtual_no 列为空或未填写 +- **WHEN** Excel 中某行第3列为空 - **THEN** 该行计入失败,失败原因为"虚拟号(virtual_no)不能为空" - **THEN** 其他合法行继续导入,不因此行中断 #### Scenario: VirtualNo 重复被拒绝 -- **WHEN** Excel 中某行的 virtual_no 与已有卡/设备的 VirtualNo 重复(跨表) +- **WHEN** Excel 中某行第3列的值与已有卡/设备的 VirtualNo 重复(跨表) - **THEN** 该行计入失败,原因为"虚拟号已被占用: <值>" -#### Scenario: Excel 文件中无 VirtualNo 列 +#### Scenario: 三列皆空时跳过不计入 total -- **WHEN** Excel 表头中不包含 `virtual_no`/`虚拟号`/`设备号` 等可识别列名 -- **THEN** 所有数据行均因 VirtualNo 为空而失败,导入结果中 `fail_count = 总行数`,`success_count = 0` -- **THEN** 返回错误提示:"Excel 文件缺少 virtual_no 列,请使用最新模板" - -#### Scenario: 批次中有 virtual_no 与现存数据重复 - -- **WHEN** Excel 中某行 virtual_no = "CARD-001",但数据库中另一张卡已有 virtual_no = "CARD-001" -- **THEN** 系统拒绝整批导入,响应返回冲突的 virtual_no 值和行号,提示"虚拟号重复,整批导入已终止" - -#### Scenario: 支持中文表头 - -- **GIVEN** Excel 文件表头为 `卡号 | 接入号 | 虚拟号` -- **WHEN** 系统解析该 Excel 文件 -- **THEN** 系统正确识别三列,按规则处理 virtual_no +- **WHEN** Excel 中某行 ICCID、MSISDN、virtual_no 三列均为空 +- **THEN** 该行视为空行跳过,不计入 total,不计入失败 #### Scenario: 导入任务完成后的结果报告 @@ -274,13 +257,12 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose #### Scenario: MSISDN 为空的行记录失败 -- **GIVEN** Excel 文件第二行 MSISDN 为空 -- **WHEN** 系统解析该 Excel 文件 -- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为"MSISDN 不能为空" +- **WHEN** Excel 中某行第2列为空 +- **THEN** 该行标记为失败,原因为"MSISDN 不能为空" #### Scenario: 长数字无损解析 -- **GIVEN** Excel 文件中 ICCID 列设置为文本格式,包含 20 位数字 "89860012345678901234" +- **GIVEN** Excel 文件中第1列设置为文本格式,包含 20 位数字 "89860012345678901234" - **WHEN** 系统解析该 Excel 文件 - **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法 @@ -324,3 +306,33 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose - **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** 系统返回参数校验错误,提示卡业务类型无效 +