feat: Excel 导入按列位置读取,IoT 卡导入支持卡业务类型
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s
- 重写 parseCardRows:移除列名识别,固定跳过第1行表头,按 col0=ICCID、 col1=MSISDN、col2=VirtualNo 取值,VirtualNo 升级为必填字段 - 重写 ParseDeviceExcel:移除 buildDeviceColumnIndex,IMEI 调整至第5列 新增 MaxSimSlots 范围校验 [1,4],整行为空时不计入 total - 删除 findCardColumns 和 buildDeviceColumnIndex 两个冗余函数 - ImportIotCardRequest 新增 card_category 字段(normal/industry,默认 normal) - IotCardImportTask model 新增 card_category 字段 - 迁移 000111:tb_iot_card_import_task 新增 card_category 列 - CreateImportTask 将 req.CardCategory 写入任务,空串兜底 normal - processBatch 改用 task.CardCategory 替代写死的 CardCategoryNormal - ImportTaskResponse 新增 card_category 字段返回 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,9 +63,10 @@ type ListStandaloneIotCardResponse struct {
|
||||
}
|
||||
|
||||
type ImportIotCardRequest struct {
|
||||
CarrierID uint `json:"carrier_id" validate:"required,min=1" required:"true" minimum:"1" description:"运营商ID"`
|
||||
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"对象存储文件路径(通过 /storage/upload-url 获取)"`
|
||||
CarrierID uint `json:"carrier_id" validate:"required,min=1" required:"true" minimum:"1" description:"运营商ID"`
|
||||
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"对象存储文件路径(通过 /storage/upload-url 获取)"`
|
||||
CardCategory string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡),默认 normal"`
|
||||
}
|
||||
|
||||
type ImportIotCardResponse struct {
|
||||
@@ -93,6 +94,7 @@ type ImportTaskResponse struct {
|
||||
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
|
||||
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
|
||||
BatchNo string `json:"batch_no,omitempty" description:"批次号"`
|
||||
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
|
||||
FileName string `json:"file_name,omitempty" description:"文件名"`
|
||||
TotalCount int `json:"total_count" description:"总数"`
|
||||
SuccessCount int `json:"success_count" description:"成功数"`
|
||||
|
||||
@@ -31,6 +31,7 @@ type IotCardImportTask struct {
|
||||
CardList CardListJSON `gorm:"column:card_list;type:jsonb;comment:待导入卡列表[{iccid,msisdn}]" json:"-"`
|
||||
StorageBucket string `gorm:"column:storage_bucket;type:varchar(100);comment:对象存储桶名" json:"storage_bucket,omitempty"`
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"`
|
||||
CardCategory string `gorm:"column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)" json:"card_category"`
|
||||
}
|
||||
|
||||
// CardItem 卡信息(ICCID + MSISDN + VirtualNo)
|
||||
|
||||
@@ -70,15 +70,21 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe
|
||||
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
|
||||
fileName := filepath.Base(req.FileKey)
|
||||
|
||||
cardCategory := req.CardCategory
|
||||
if cardCategory == "" {
|
||||
cardCategory = constants.CardCategoryNormal
|
||||
}
|
||||
|
||||
task := &model.IotCardImportTask{
|
||||
TaskNo: taskNo,
|
||||
Status: model.ImportTaskStatusPending,
|
||||
CarrierID: req.CarrierID,
|
||||
CarrierType: carrier.CarrierType,
|
||||
CarrierName: carrier.CarrierName,
|
||||
BatchNo: req.BatchNo,
|
||||
FileName: fileName,
|
||||
StorageKey: req.FileKey,
|
||||
TaskNo: taskNo,
|
||||
Status: model.ImportTaskStatusPending,
|
||||
CarrierID: req.CarrierID,
|
||||
CarrierType: carrier.CarrierType,
|
||||
CarrierName: carrier.CarrierName,
|
||||
BatchNo: req.BatchNo,
|
||||
FileName: fileName,
|
||||
StorageKey: req.FileKey,
|
||||
CardCategory: cardCategory,
|
||||
}
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
@@ -202,6 +208,7 @@ func (s *Service) toTaskResponse(task *model.IotCardImportTask) *dto.ImportTaskR
|
||||
CarrierType: task.CarrierType,
|
||||
CarrierName: task.CarrierName,
|
||||
BatchNo: task.BatchNo,
|
||||
CardCategory: task.CardCategory,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
|
||||
@@ -354,7 +354,7 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
CarrierName: task.CarrierName,
|
||||
BatchNo: task.BatchNo,
|
||||
Status: constants.IotCardStatusInStock,
|
||||
CardCategory: constants.CardCategoryNormal,
|
||||
CardCategory: task.CardCategory,
|
||||
ActivationStatus: constants.ActivationStatusInactive,
|
||||
RealNameStatus: constants.RealNameStatusNotVerified,
|
||||
NetworkStatus: constants.NetworkStatusOffline,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 回滚:删除 card_category 列
|
||||
ALTER TABLE tb_iot_card_import_task
|
||||
DROP COLUMN IF EXISTS card_category;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 为 tb_iot_card_import_task 新增 card_category 列,记录导入批次的卡业务类型
|
||||
ALTER TABLE tb_iot_card_import_task
|
||||
ADD COLUMN card_category VARCHAR(20) NOT NULL DEFAULT 'normal';
|
||||
|
||||
COMMENT ON COLUMN tb_iot_card_import_task.card_category IS '卡业务类型(normal:普通卡/industry:行业卡)';
|
||||
2
openspec/changes/excel-import-refactor/.openspec.yaml
Normal file
2
openspec/changes/excel-import-refactor/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-09
|
||||
57
openspec/changes/excel-import-refactor/design.md
Normal file
57
openspec/changes/excel-import-refactor/design.md
Normal file
@@ -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
|
||||
|
||||
(无)
|
||||
31
openspec/changes/excel-import-refactor/proposal.md
Normal file
31
openspec/changes/excel-import-refactor/proposal.md
Normal file
@@ -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`,确保列顺序与代码一致(高风险:列顺序不匹配会导致数据静默错位)
|
||||
@@ -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** 该行被跳过,记录跳过原因"设备号已存在"
|
||||
@@ -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** 系统返回参数校验错误,提示卡业务类型无效
|
||||
33
openspec/changes/excel-import-refactor/tasks.md
Normal file
33
openspec/changes/excel-import-refactor/tasks.md
Normal file
@@ -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` 时验证返回参数校验错误
|
||||
@@ -120,55 +120,50 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
|
||||
return nil, ErrExcelNoData
|
||||
}
|
||||
|
||||
header := rows[0]
|
||||
colIndex := buildDeviceColumnIndex(header)
|
||||
|
||||
parseResult := &DeviceParseResult{
|
||||
Rows: make([]DeviceRow, 0),
|
||||
ParseErrors: make([]CSVParseError, 0),
|
||||
}
|
||||
|
||||
// 按固定列位置读取,第1行为表头跳过:
|
||||
// col0=虚拟号 col1=设备名称 col2=设备型号 col3=设备类型 col4=IMEI
|
||||
// col5=制造商 col6=最大SIM槽数 col7~10=卡1~4 ICCID
|
||||
for i := 1; i < len(rows); i++ {
|
||||
record := rows[i]
|
||||
lineNum := i + 1
|
||||
|
||||
row := DeviceRow{Line: lineNum}
|
||||
|
||||
if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) {
|
||||
row.VirtualNo = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["imei"]; idx >= 0 && idx < len(record) {
|
||||
row.IMEI = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceName = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_model"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceModel = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_type"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceType = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["max_sim_slots"]; idx >= 0 && idx < len(record) {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(record[idx])); err == nil {
|
||||
row.MaxSimSlots = n
|
||||
getCol := func(idx int) string {
|
||||
if idx < len(record) {
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
}
|
||||
if idx := colIndex["manufacturer"]; idx >= 0 && idx < len(record) {
|
||||
row.Manufacturer = strings.TrimSpace(record[idx])
|
||||
return ""
|
||||
}
|
||||
|
||||
row.VirtualNo = getCol(0)
|
||||
row.DeviceName = getCol(1)
|
||||
row.DeviceModel = getCol(2)
|
||||
row.DeviceType = getCol(3)
|
||||
row.IMEI = getCol(4)
|
||||
row.Manufacturer = getCol(5)
|
||||
maxSimSlotsStr := getCol(6)
|
||||
|
||||
row.ICCIDs = make([]string, 0, 4)
|
||||
for j := 1; j <= 4; j++ {
|
||||
colName := "iccid_" + strconv.Itoa(j)
|
||||
if idx := colIndex[colName]; idx >= 0 && idx < len(record) {
|
||||
iccid := strings.TrimSpace(record[idx])
|
||||
if iccid != "" {
|
||||
row.ICCIDs = append(row.ICCIDs, iccid)
|
||||
}
|
||||
for j := 7; j <= 10; j++ {
|
||||
iccid := getCol(j)
|
||||
if iccid != "" {
|
||||
row.ICCIDs = append(row.ICCIDs, iccid)
|
||||
}
|
||||
}
|
||||
|
||||
// 整行目标列皆为空,视为空行跳过,不计入 total
|
||||
if row.VirtualNo == "" && row.DeviceName == "" && row.DeviceModel == "" &&
|
||||
row.DeviceType == "" && row.IMEI == "" && row.Manufacturer == "" &&
|
||||
maxSimSlotsStr == "" && len(row.ICCIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
parseResult.TotalCount++
|
||||
|
||||
if row.VirtualNo == "" {
|
||||
@@ -179,8 +174,22 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析最大SIM槽数
|
||||
if maxSimSlotsStr != "" {
|
||||
if n, err := strconv.Atoi(maxSimSlotsStr); err == nil {
|
||||
row.MaxSimSlots = n
|
||||
}
|
||||
}
|
||||
|
||||
// 范围校验:不在 [1,4] 则失败;为空/0 时回填默认值 4
|
||||
if row.MaxSimSlots == 0 {
|
||||
row.MaxSimSlots = 4
|
||||
} else if row.MaxSimSlots < 1 || row.MaxSimSlots > 4 {
|
||||
parseResult.ParseErrors = append(parseResult.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
Reason: "最大SIM槽数必须在1-4之间",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
parseResult.Rows = append(parseResult.Rows, row)
|
||||
@@ -209,70 +218,44 @@ func selectSheet(f *excelize.File) string {
|
||||
}
|
||||
|
||||
// parseCardRows 解析卡数据行
|
||||
// 自动检测表头并提取ICCID和MSISDN列
|
||||
// 第1行固定为表头跳过,按列位置取值:col0=ICCID、col1=MSISDN、col2=VirtualNo
|
||||
func parseCardRows(rows [][]string) (*CSVParseResult, error) {
|
||||
result := &CSVParseResult{
|
||||
Cards: make([]CardInfo, 0),
|
||||
ParseErrors: make([]CSVParseError, 0),
|
||||
}
|
||||
|
||||
headerSkipped := false
|
||||
iccidCol, msisdnCol, virtualNoCol := -1, -1, -1
|
||||
|
||||
if len(rows) > 0 {
|
||||
iccidCol, msisdnCol, virtualNoCol = findCardColumns(rows[0])
|
||||
if iccidCol >= 0 && msisdnCol >= 0 {
|
||||
headerSkipped = true
|
||||
}
|
||||
}
|
||||
|
||||
startLine := 0
|
||||
if headerSkipped {
|
||||
startLine = 1
|
||||
}
|
||||
|
||||
for i := startLine; i < len(rows); i++ {
|
||||
// 从第2行(索引1)开始,跳过第1行表头
|
||||
for i := 1; i < len(rows); i++ {
|
||||
row := rows[i]
|
||||
lineNum := i + 1
|
||||
|
||||
if len(row) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
iccid := ""
|
||||
msisdn := ""
|
||||
virtualNo := ""
|
||||
|
||||
if iccidCol >= 0 {
|
||||
if iccidCol < len(row) {
|
||||
iccid = strings.TrimSpace(row[iccidCol])
|
||||
}
|
||||
if msisdnCol < len(row) {
|
||||
msisdn = strings.TrimSpace(row[msisdnCol])
|
||||
}
|
||||
if virtualNoCol >= 0 && virtualNoCol < len(row) {
|
||||
virtualNo = strings.TrimSpace(row[virtualNoCol])
|
||||
}
|
||||
} else {
|
||||
if len(row) >= 1 {
|
||||
iccid = strings.TrimSpace(row[0])
|
||||
}
|
||||
if len(row) >= 2 {
|
||||
msisdn = strings.TrimSpace(row[1])
|
||||
}
|
||||
if len(row) >= 1 {
|
||||
iccid = strings.TrimSpace(row[0])
|
||||
}
|
||||
if len(row) >= 2 {
|
||||
msisdn = strings.TrimSpace(row[1])
|
||||
}
|
||||
if len(row) >= 3 {
|
||||
virtualNo = strings.TrimSpace(row[2])
|
||||
}
|
||||
|
||||
// 三列皆为空视为空行,跳过且不计入 total
|
||||
if iccid == "" && msisdn == "" && virtualNo == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
result.TotalCount++
|
||||
|
||||
if iccid == "" && msisdn == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if iccid == "" {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
MSISDN: msisdn,
|
||||
Reason: "ICCID不能为空",
|
||||
Reason: "ICCID 不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -281,7 +264,16 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
ICCID: iccid,
|
||||
Reason: "MSISDN不能为空",
|
||||
Reason: "MSISDN 不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if virtualNo == "" {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
ICCID: iccid,
|
||||
Reason: "虚拟号(virtual_no)不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -296,68 +288,3 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// findCardColumns 查找ICCID、MSISDN和VirtualNo列索引
|
||||
// 支持中英文列名识别
|
||||
func findCardColumns(header []string) (iccidCol, msisdnCol, virtualNoCol int) {
|
||||
iccidCol, msisdnCol, virtualNoCol = -1, -1, -1
|
||||
|
||||
for i, col := range header {
|
||||
colLower := strings.ToLower(strings.TrimSpace(col))
|
||||
|
||||
if colLower == "iccid" || colLower == "卡号" || colLower == "号码" {
|
||||
if iccidCol == -1 {
|
||||
iccidCol = i
|
||||
}
|
||||
}
|
||||
|
||||
if colLower == "msisdn" || colLower == "接入号" || colLower == "手机号" || colLower == "电话" {
|
||||
if msisdnCol == -1 {
|
||||
msisdnCol = i
|
||||
}
|
||||
}
|
||||
|
||||
if colLower == "virtual_no" || colLower == "virtualno" || colLower == "虚拟号" || colLower == "设备号" {
|
||||
if virtualNoCol == -1 {
|
||||
virtualNoCol = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iccidCol, msisdnCol, virtualNoCol
|
||||
}
|
||||
|
||||
// buildDeviceColumnIndex 构建设备导入列索引
|
||||
// 识别表头中的列名,返回列名到列索引的映射
|
||||
// IMEI 列支持多种列名格式:imei/IMEI/设备IMEI/IMEI号
|
||||
func buildDeviceColumnIndex(header []string) map[string]int {
|
||||
index := map[string]int{
|
||||
"virtual_no": -1,
|
||||
"imei": -1,
|
||||
"device_name": -1,
|
||||
"device_model": -1,
|
||||
"device_type": -1,
|
||||
"max_sim_slots": -1,
|
||||
"manufacturer": -1,
|
||||
"iccid_1": -1,
|
||||
"iccid_2": -1,
|
||||
"iccid_3": -1,
|
||||
"iccid_4": -1,
|
||||
}
|
||||
|
||||
for i, col := range header {
|
||||
normalized := strings.ToLower(strings.TrimSpace(col))
|
||||
// 支持 IMEI 多种列名格式
|
||||
switch normalized {
|
||||
case "imei", "设备imei", "imei号":
|
||||
if index["imei"] == -1 {
|
||||
index["imei"] = i
|
||||
}
|
||||
default:
|
||||
if _, exists := index[normalized]; exists {
|
||||
index[normalized] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user