diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index 0bc6083..3d530bd 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -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:"成功数"` diff --git a/internal/model/iot_card_import_task.go b/internal/model/iot_card_import_task.go index 4ac9cba..f9b2ea1 100644 --- a/internal/model/iot_card_import_task.go +++ b/internal/model/iot_card_import_task.go @@ -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) diff --git a/internal/service/iot_card_import/service.go b/internal/service/iot_card_import/service.go index 0bb526d..04070ca 100644 --- a/internal/service/iot_card_import/service.go +++ b/internal/service/iot_card_import/service.go @@ -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, diff --git a/internal/task/iot_card_import.go b/internal/task/iot_card_import.go index 6ba6517..09b0be6 100644 --- a/internal/task/iot_card_import.go +++ b/internal/task/iot_card_import.go @@ -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, diff --git a/migrations/000111_add_card_category_to_import_task.down.sql b/migrations/000111_add_card_category_to_import_task.down.sql new file mode 100644 index 0000000..5fdc817 --- /dev/null +++ b/migrations/000111_add_card_category_to_import_task.down.sql @@ -0,0 +1,3 @@ +-- 回滚:删除 card_category 列 +ALTER TABLE tb_iot_card_import_task + DROP COLUMN IF EXISTS card_category; diff --git a/migrations/000111_add_card_category_to_import_task.up.sql b/migrations/000111_add_card_category_to_import_task.up.sql new file mode 100644 index 0000000..f0e6eb0 --- /dev/null +++ b/migrations/000111_add_card_category_to_import_task.up.sql @@ -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:行业卡)'; diff --git a/openspec/changes/excel-import-refactor/.openspec.yaml b/openspec/changes/excel-import-refactor/.openspec.yaml new file mode 100644 index 0000000..98d7681 --- /dev/null +++ b/openspec/changes/excel-import-refactor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-09 diff --git a/openspec/changes/excel-import-refactor/design.md b/openspec/changes/excel-import-refactor/design.md new file mode 100644 index 0000000..e41ae86 --- /dev/null +++ b/openspec/changes/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/excel-import-refactor/proposal.md b/openspec/changes/excel-import-refactor/proposal.md new file mode 100644 index 0000000..31722a4 --- /dev/null +++ b/openspec/changes/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/excel-import-refactor/specs/device-import/spec.md b/openspec/changes/excel-import-refactor/specs/device-import/spec.md new file mode 100644 index 0000000..f2d0e15 --- /dev/null +++ b/openspec/changes/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/excel-import-refactor/specs/iot-card-import-task/spec.md b/openspec/changes/excel-import-refactor/specs/iot-card-import-task/spec.md new file mode 100644 index 0000000..96003f2 --- /dev/null +++ b/openspec/changes/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/excel-import-refactor/tasks.md b/openspec/changes/excel-import-refactor/tasks.md new file mode 100644 index 0000000..ef15a07 --- /dev/null +++ b/openspec/changes/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/pkg/utils/excel.go b/pkg/utils/excel.go index c83db90..9fbcef6 100644 --- a/pkg/utils/excel.go +++ b/pkg/utils/excel.go @@ -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 -}