feat: Excel 导入按列位置读取,IoT 卡导入支持卡业务类型
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:
2026-04-09 17:16:31 +08:00
parent ff7e749bf2
commit cd807e58e3
13 changed files with 419 additions and 152 deletions

View File

@@ -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:"成功数"`

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,3 @@
-- 回滚:删除 card_category 列
ALTER TABLE tb_iot_card_import_task
DROP COLUMN IF EXISTS card_category;

View File

@@ -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:行业卡)';

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-09

View 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按列位置读取完全移除列名识别
**选择**: 直接按列索引取值,第一行永远跳过(表头行)。
**备选方案**: 在现有映射中添加中文列名别名。
**理由**: 中文别名方案需要为每个字段定义多套名称(中文/英文/变体),维护成本高且仍然脆弱(业务随时可能换叫法)。按位置读取更简单,也更明确——模板即契约,列顺序固定。
### 决策2card_category 放在请求参数而非 Excel 列
**选择**: 在 `ImportIotCardRequest``IotCardImportTask` 中加 `card_category` 字段,不在 Excel 里加列。
**理由**: 业务现实是一批卡要么全是行业卡,要么全是普通卡,不会混批。前端用下拉选择比 Excel 里填枚举值更直观,也不影响 Excel 列顺序。
### 决策3card_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
(无)

View 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`,确保列顺序与代码一致(高风险:列顺序不匹配会导致数据静默错位)

View File

@@ -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** 该行被跳过,记录跳过原因"设备号已存在"

View File

@@ -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** 系统返回参数校验错误,提示卡业务类型无效

View 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` 时验证返回参数校验错误

View File

@@ -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
}