设备导入卡槽不对的问题

This commit is contained in:
2026-04-30 11:56:09 +08:00
parent 9238eeb56e
commit 8a3c45c577
10 changed files with 478 additions and 32 deletions

View File

@@ -114,10 +114,10 @@ function validateFile(file) {
**文件名**: `device_import_template.xlsx`
**格式**:
| virtual_no | device_name | device_model | device_type | max_sim_slots | manufacturer | imei | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|------------|-------------|--------------|-------------|---------------|--------------|------|---------|---------|---------|---------|
| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | 123456789012345 | 89860012345678901234 | 89860012345678901235 | | |
| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | 123456789012346 | 89860012345678901236 | | | |
| virtual_no | device_name | device_model | device_type | imei | manufacturer | max_sim_slots | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|------------|-------------|--------------|-------------|------|--------------|---------------|---------|---------|---------|---------|
| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 123456789012345 | Concox | 4 | 89860012345678901234 | 89860012345678901235 | | |
| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 123456789012346 | Concox | 2 | | 89860012345678901236 | 89860012345678901237 | |
**字段说明**:
@@ -127,15 +127,19 @@ function validateFile(file) {
| device_name | 否 | 设备名称 |
| device_model | 否 | 设备型号 |
| device_type | 否 | 设备类型 |
| max_sim_slots | 否 | 最大SIM卡槽数默认4 |
| manufacturer | 否 | 制造商 |
| imei | 否 | 设备IMEI |
| iccid_1 ~ iccid_4 | 否 | 绑定的IoT卡ICCID填写时对应ICCID必须已存在于系统中 |
| manufacturer | 否 | 制造商 |
| max_sim_slots | 否 | 最大SIM卡槽数默认4范围1-4 |
| iccid_1 ~ iccid_4 | 否 | 固定槽位的 IoT 卡 ICCID`iccid_1`=槽1`iccid_2`=槽2`iccid_3`=槽3`iccid_4`=槽4中间留空不会触发“前移补位” |
**要点**:
- 所有列都必须设置为**文本格式**
- `virtual_no` 为必填项,留空将记录为失败行(而非静默跳过)
- iccid_1 ~ iccid_4为可选项填写时对应的ICCID必须已存在于系统中
- 设备导入按**固定列顺序**读取:`virtual_no``device_name``device_model``device_type``imei``manufacturer``max_sim_slots``iccid_1``iccid_2``iccid_3``iccid_4`
- `iccid_1 ~ iccid_4` 为可选项,填写时对应的 ICCID 必须已存在于系统中
- `iccid_1 ~ iccid_4` 表示**固定槽位**而非“按非空顺序绑定”
例如只填写 `iccid_2``iccid_3`导入后仍应绑定到槽2和槽3而不是槽1和槽2
- 如果 `max_sim_slots=2`,则 `iccid_3``iccid_4` 必须留空;否则该行应导入失败
## 模板下载功能实现

View File

@@ -92,9 +92,11 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
- ` + "`device_name`" + `: 设备名称
- ` + "`device_model`" + `: 设备型号
- ` + "`device_type`" + `: 设备类型
- ` + "`max_sim_slots`" + `: 最大插槽数默认4
- ` + "`imei`" + `: 设备 IMEI
- ` + "`manufacturer`" + `: 制造商
- ` + "`iccid_1`" + ` ~ ` + "`iccid_4`" + `: 绑定的卡 ICCID卡必须已存在且未绑定
- ` + "`max_sim_slots`" + `: 最大插槽数默认4范围1-4
- ` + "`iccid_1`" + ` ~ ` + "`iccid_4`" + `: 固定槽位的卡 ICCID` + "`iccid_1`" + `=槽1` + "`iccid_2`" + `=槽2` + "`iccid_3`" + `=槽3` + "`iccid_4`" + `=槽4卡必须已存在且未绑定
- 槽位规则:中间留空不会触发前移补位;如果 ` + "`max_sim_slots=2`" + `,则 ` + "`iccid_3`" + `` + "`iccid_4`" + ` 必须留空,否则该行失败
- 列格式:设置为文本格式(避免长数字被转为科学记数法)`,
Tags: []string{"设备管理"},
Input: new(dto.ImportDeviceRequest),

View File

@@ -196,7 +196,9 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
for _, row := range batch {
deviceNos = append(deviceNos, row.VirtualNo)
allICCIDs = append(allICCIDs, row.ICCIDs...)
for _, slotICCID := range row.SlotICCIDs {
allICCIDs = append(allICCIDs, slotICCID.ICCID)
}
}
existingDevices, err := h.deviceStore.ExistsByDeviceNoBatch(ctx, deviceNos)
@@ -243,27 +245,49 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
continue
}
var validCardIDs []uint
var cardIssues []string
for _, iccid := range row.ICCIDs {
card, exists := existingCards[iccid]
if !exists {
cardIssues = append(cardIssues, iccid+"不存在")
rowFailed := false
for _, slotICCID := range row.SlotICCIDs {
if slotICCID.SlotPosition <= row.MaxSimSlots {
continue
}
if boundCards[iccid] {
cardIssues = append(cardIssues, iccid+"已绑定其他设备")
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.VirtualNo,
Reason: "卡槽填写超出最大SIM槽数",
})
result.failCount++
rowFailed = true
break
}
if rowFailed {
continue
}
var validCards []utils.DeviceSlotICCID
var cardIssues []string
for _, slotICCID := range row.SlotICCIDs {
card, exists := existingCards[slotICCID.ICCID]
if !exists {
cardIssues = append(cardIssues, slotICCID.ICCID+"不存在")
continue
}
if boundCards[slotICCID.ICCID] {
cardIssues = append(cardIssues, slotICCID.ICCID+"已绑定其他设备")
continue
}
if card.ShopID != nil {
cardIssues = append(cardIssues, iccid+"已分配给店铺,不能绑定到平台库存设备")
cardIssues = append(cardIssues, slotICCID.ICCID+"已分配给店铺,不能绑定到平台库存设备")
continue
}
validCardIDs = append(validCardIDs, card.ID)
validCards = append(validCards, utils.DeviceSlotICCID{
SlotPosition: slotICCID.SlotPosition,
ICCID: slotICCID.ICCID,
})
}
if len(row.ICCIDs) > 0 && len(cardIssues) > 0 {
if len(row.SlotICCIDs) > 0 && len(cardIssues) > 0 {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.VirtualNo,
@@ -303,11 +327,15 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
}
now := time.Now()
for i, cardID := range validCardIDs {
validCardIDs := make([]uint, 0, len(validCards))
for _, slotICCID := range validCards {
cardID := existingCards[slotICCID.ICCID].ID
validCardIDs = append(validCardIDs, cardID)
binding := &model.DeviceSimBinding{
DeviceID: device.ID,
IotCardID: cardID,
SlotPosition: i + 1,
SlotPosition: slotICCID.SlotPosition,
BindStatus: 1,
BindTime: &now,
}
@@ -354,9 +382,9 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
continue
}
for _, iccid := range row.ICCIDs {
if card, exists := existingCards[iccid]; exists && !boundCards[iccid] && card.ShopID == nil {
boundCards[iccid] = true
for _, slotICCID := range row.SlotICCIDs {
if card, exists := existingCards[slotICCID.ICCID]; exists && !boundCards[slotICCID.ICCID] && card.ShopID == nil {
boundCards[slotICCID.ICCID] = true
}
}

View File

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

View File

@@ -0,0 +1,111 @@
## Context
当前设备导入链路分为两段:
1. `pkg/utils/excel.go` 负责按固定列读取设备导入 Excel。
2. `internal/task/device_import.go` 负责校验卡并创建 `tb_device_sim_binding` 记录。
现状问题在于解析层只保留“非空 ICCID 列表”,没有保留“这个 ICCID 原来来自第几个卡槽列”。任务层随后按数组顺序写入 `slot_position = i + 1`,使得源文件中的空槽位被压缩。这个问题不涉及新接口,也不需要调整数据库结构,但会跨越解析层与任务执行层,适合先通过设计文档把数据语义钉死。
约束条件:
- 必须遵守现有 `Handler → Service → Store → Model` 分层,不把槽位推断逻辑散落到多处。
- 不新增表结构,不引入新依赖。
- 设备导入仍走 Asynq 异步任务,保持当前事务边界和幂等行为。
- 文档、日志、错误消息保持中文。
## Goals / Non-Goals
**Goals:**
- 保留 Excel `卡1~卡4` 的原始槽位信息,避免前置空槽被压缩。
- 让导入创建的 `slot_position` 与 Excel 列号严格一致。
-`max_sim_slots` 之外的已填写 ICCID 给出明确失败规则。
- 让后续查询设备绑定关系、切卡和运维判断都基于正确槽位数据。
**Non-Goals:**
- 不新增或修改导入 API。
- 不修复历史上已经错误导入的数据。
- 不改变设备导入任务的权限、批次、实名策略等既有能力。
- 不引入自动化测试要求,本次仅定义实现任务和手工验收路径。
## Decisions
### 决策1在解析结果中保留“槽位 + ICCID”映射而不是仅保留 ICCID 列表
**选择**
设备导入解析结构需要显式表达每张卡来自哪个槽位,例如保留 `slot_position``iccid` 的成对关系,或者使用等价的固定槽位结构。
**理由**
- 只有解析层知道 ICCID 来自哪一列,到了任务层再推断已经丢失上下文。
- 把槽位语义前置到解析结果,可以让任务层只负责校验和落库,职责更清晰。
**备选方案**
- 在任务层根据 ICCID 顺序反推槽位:不可行,前置空槽信息已丢失。
- 用占位空字符串继续传 `[]string`:可读性差,容易在后续处理中再次被压缩或忽略。
### 决策2绑定记录的 `slot_position` 直接使用源列位,不再使用连续下标
**选择**
创建设备卡绑定时,`slot_position` 必须直接使用解析出的槽位编号,而不是 `i + 1`
**理由**
- `tb_device_sim_binding.slot_position` 本来就是槽位语义字段,应该存真实槽位。
- 这样可以保持导入、查询、切卡、展示四个环节的语义一致。
**备选方案**
- 继续写连续下标并在展示层修正:会污染底层数据,后续所有依赖槽位的逻辑都要补丁式兼容,不可接受。
### 决策3填写超过 `max_sim_slots` 的 ICCID 时整行失败
**选择**
如果 Excel 某行声明 `max_sim_slots=2`,却填写了 `卡3``卡4`,该行导入失败,并给出明确失败原因。
**理由**
- 超出设备最大槽位的绑定关系本身就是无效数据,不能静默忽略。
- 失败比“部分导入 + 隐式丢弃”更容易让运营发现模板问题。
**备选方案**
- 静默忽略越界槽位:会造成运营以为导入成功,但设备绑定不完整。
- 自动上调 `max_sim_slots`:篡改源数据含义,不符合设备真实硬件能力。
### 决策4沿用现有事务边界不扩大导入任务架构
**选择**
仍在当前单行事务中完成设备创建、资产标识注册、卡绑定创建、卡快照更新和钱包创建,只替换槽位数据来源。
**理由**
- 问题根因是数据语义错误,不是事务模型错误。
- 保持现有事务边界可以减少改动范围和回归风险。
## Risks / Trade-offs
- [风险] 解析结构变更会波及设备导入任务中遍历 ICCID 的逻辑
→ Mitigation只在设备导入专用结构中调整不影响卡导入等其他解析路径。
- [风险] 历史错误导入的数据仍然存在,修复后新旧数据表现不一致
→ Mitigation在提案中明确本次仅修复“后续导入行为”历史数据如需修复需单独立项。
- [风险] 运营模板可能继续填写越界槽位,修复后失败数会上升
→ Mitigation在失败原因中明确指出“填写槽位超出最大SIM槽数”并更新导入使用说明。
## Migration Plan
1. 先更新 `device-import` 规格,明确“列位即槽位”。
2. 调整设备导入解析结构与任务执行逻辑。
3. 使用手工构造的 Excel 样例做导入验收,覆盖空槽保留和越界失败两类场景。
4. 上线后仅影响新导入任务,无需数据迁移。
5. 如需回滚,恢复解析和绑定逻辑即可;本次不涉及表结构变更。
## Open Questions
- 历史错误导入的数据是否需要补录或脚本修正;如果需要,应拆成独立变更处理。
- 前端模板文案是否要把 `iccid_1 ~ iccid_4` 明确标注为“槽1 ~ 槽4”这会影响使用体验但不阻塞后端修复。

View File

@@ -0,0 +1,32 @@
## Why
feature-2026-04-device-import-slot-mapping
当前设备导入会把 Excel 中非空 ICCID 列压缩成连续列表导致“卡2、卡3”这类原始槽位信息在导入时丢失最终被错误写入为“槽1、槽2”。该问题会直接造成设备与卡槽关系失真影响后续设备查询、切卡和运维判断需要尽快在规格和实现层统一修正。
## What Changes
- 修改设备导入规格,明确 `卡1~卡4` 列表示固定槽位,导入时必须保留原始列位信息,不得按非空顺序重排。
- 修改设备导入执行规则,设备卡绑定记录的 `slot_position` 必须与 Excel 列号一一对应。
- 新增越界约束:当 `max_sim_slots` 小于某个已填写 ICCID 的槽位编号时,该行导入失败,避免写入超出设备最大槽位数的绑定关系。
- 补充典型场景覆盖“仅填写卡2/卡3”“前置空槽保留”“超出最大槽位失败”等业务行为。
## Capabilities
### New Capabilities
无新增功能
### Modified Capabilities
- `device-import`: 设备导入的槽位语义从“按非空 ICCID 顺序绑定”调整为“按 Excel 固定列位绑定到对应槽位”,并补充超出 `max_sim_slots` 的失败规则。
## Impact
- 受影响规格:`openspec/specs/device-import/spec.md`
- 受影响代码:
- `pkg/utils/excel.go` 中设备导入行的 ICCID 解析结构
- `internal/task/device_import.go` 中设备卡绑定创建逻辑
- 受影响数据:新导入设备的 `tb_device_sim_binding.slot_position` 写入规则
- 受影响业务链路:设备详情、设备卡绑定列表、依赖槽位判断的运维操作
- 依赖与接口:不新增外部依赖,不新增 API仅修正既有导入行为

View File

@@ -0,0 +1,178 @@
# device-import 规格增量
## 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可选对应槽位1
第9列索引8: 卡2 ICCID可选对应槽位2
第10列索引9: 卡3 ICCID可选对应槽位3
第11列索引10: 卡4 ICCID可选对应槽位4
```
- **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法)
**失败原因文本**:
- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"`
- MaxSimSlots 越界:`"最大SIM槽数必须在1-4之间"`
- ICCID 填写槽位超出最大槽位:`"卡槽填写超出最大SIM槽数"`
**导入规则**:
- 按列索引取值,不识别列名,第一行永远跳过
- 若整行目标列皆为空,视为空行跳过,不计入 total不计入失败
- MaxSimSlots 为空或 0 回填默认值 4非空且不在 [1,4] 记录为失败
- `卡1~卡4` 的列位本身就是槽位定义,导入时必须保留原始列位信息,不得按非空 ICCID 顺序重排
- 前置槽位允许为空;如果仅填写 `卡2`、`卡3`,则表示设备只在槽位 2、3 上绑定卡
- 任一已填写 ICCID 的槽位编号若大于 `max_sim_slots`,则该行导入失败
- 导入的设备 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: 仅填写卡2和卡3时保留原始槽位
- **GIVEN** 某行 `最大SIM槽数 = 3`
- **AND** `卡1` 为空,`卡2 = ICCID_A``卡3 = ICCID_B`
- **WHEN** 系统解析该行
- **THEN** 系统将 `ICCID_A` 识别为槽位2的卡
- **AND** 系统将 `ICCID_B` 识别为槽位3的卡
- **AND** 系统不得将其重排为槽位1和槽位2
#### Scenario: 填写槽位超出最大SIM槽数
- **GIVEN** 某行 `最大SIM槽数 = 2`
- **AND** `卡3 = ICCID_C`
- **WHEN** 系统解析该行
- **THEN** 该行导入失败
- **AND** 失败原因为"卡槽填写超出最大SIM槽数"
#### 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)不能为空"
- **AND** 其他合法行继续导入,不因此行中断
### Requirement: 设备导入任务执行
系统 SHALL 异步执行设备导入任务,逐行处理 Excel 数据。
**处理规则**:
- 打开Excel文件选择第一个sheet或优先"导入数据"sheet
- 跳过第1行表头行从第2行开始按固定列位置解析数据
- 若整行目标列皆为空,视为空行跳过,不计入 total
- 逐行解析数据,并保留每个 ICCID 对应的原始槽位编号
- 对每行数据执行以下校验:
1. 设备号是否已存在(已存在则跳过)
2. ICCID 是否存在于系统中(不存在则失败)
3. ICCID 是否已绑定其他设备(已绑定则失败)
4. 已填写 ICCID 的槽位是否超出该设备 `max_sim_slots`(超出则失败)
- 校验通过后:
1. 创建设备记录
2. 按 ICCID 的原始槽位创建 `slot_position` 一致的设备-卡绑定记录
- 记录处理结果(成功/跳过/失败)
**任务状态**:
- 1: 待处理
- 2: 处理中
- 3: 已完成
- 4: 失败
#### Scenario: 导入成功
- **WHEN** Excel 中所有设备号不重复且 ICCID 有效
- **THEN** 系统创建所有设备和绑定记录,任务状态为"已完成"
#### Scenario: 前置空槽导入成功
- **GIVEN** 某行 `卡1` 为空,`卡2 = ICCID_A``卡3 = ICCID_B`
- **WHEN** 该行校验通过并执行导入
- **THEN** 系统创建两条绑定记录
- **AND** `ICCID_A` 的 `slot_position = 2`
- **AND** `ICCID_B` 的 `slot_position = 3`
#### Scenario: 部分导入成功
- **WHEN** Excel 中部分设备号已存在或部分 ICCID 无效
- **THEN** 系统只导入有效的行,记录跳过和失败的详情,任务状态为"已完成"
#### Scenario: ICCID 不存在
- **WHEN** Excel 中某行的 ICCID 在系统中不存在
- **THEN** 该行导入失败,记录失败原因"ICCID 不存在"
#### Scenario: ICCID 已绑定其他设备
- **WHEN** Excel 中某行的 ICCID 已绑定到其他设备
- **THEN** 该行导入失败,记录失败原因"ICCID 已绑定其他设备"
#### Scenario: 设备号重复
- **WHEN** Excel 中某行的设备号在系统中已存在
- **THEN** 该行被跳过,记录跳过原因"设备号已存在"
#### Scenario: 填写槽位超出最大SIM槽数时任务记录失败
- **WHEN** Excel 中某行声明 `最大SIM槽数 = 2`,但填写了 `卡3` 或 `卡4`
- **THEN** 该行计入失败(`fail_count++`
- **AND** 失败明细中的 `reason` 字段为"卡槽填写超出最大SIM槽数"
#### Scenario: 导入任务结果报告包含 VirtualNo 失败原因
- **WHEN** 导入任务处理完毕,含有 VirtualNo 为空的行
- **THEN** 失败明细列表中,该行的 `reason` 字段为"设备虚拟号(virtual_no)不能为空"`line` 字段为对应行号

View File

@@ -0,0 +1,17 @@
## 1. 规格与解析模型调整
- [x] 1.1 调整设备导入解析结构,显式保留每个 ICCID 的原始槽位编号,避免在 `pkg/utils/excel.go` 中压缩前置空槽。
- [x] 1.2 更新设备导入解析逻辑,使 `卡1~卡4` 始终按固定列位映射为槽位 1~4并补充“填写槽位超出最大SIM槽数”的失败原因。
- [x] 1.3 运行 `gofmt``lsp_diagnostics`,确认解析层修改后无格式和静态诊断错误。
## 2. 导入任务绑定逻辑修正
- [x] 2.1 修改 `internal/task/device_import.go` 的设备卡校验与绑定创建流程,改为使用解析得到的原始槽位写入 `slot_position`
- [x] 2.2 保持现有单行事务边界不变,补充越界槽位失败处理,确保错误信息与规格一致。
- [x] 2.3 运行 `gofmt``lsp_diagnostics`,确认任务层修改后无格式和静态诊断错误。
## 3. 手工验收与文档同步
- [x] 3.1 准备手工验收样例覆盖“仅填写卡2/卡3保留槽位”和“填写槽位超出最大SIM槽数失败”两类 Excel 数据。
- [ ] 3.2 在本地执行设备导入手工验收,确认导入结果中的绑定槽位与 Excel 原始列位一致,失败场景返回预期原因。
- [x] 3.3 同步更新相关导入说明文档或模板说明,明确 `iccid_1 ~ iccid_4` 表示固定槽位而非连续顺序。

View File

@@ -0,0 +1,50 @@
# 设备导入槽位映射手工验收样例
本文档用于 `fix-device-import-slot-mapping` 提案的手工验收准备,覆盖“保留原始槽位”和“超出最大槽位失败”两类场景。
## Excel 表头
请在本地新建 `.xlsx` 文件,并使用以下固定表头顺序:
| virtual_no | device_name | device_model | device_type | imei | manufacturer | max_sim_slots | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|------------|-------------|--------------|-------------|------|--------------|---------------|---------|---------|---------|---------|
所有列必须设置为文本格式。
## 样例 1仅填写卡2/卡3保留原始槽位
预期:导入成功后,绑定关系仍然写入槽位 2 和槽位 3不得前移为槽位 1 和槽位 2。
| virtual_no | device_name | device_model | device_type | imei | manufacturer | max_sim_slots | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|------------|-------------|--------------|-------------|------|--------------|---------------|---------|---------|---------|---------|
| SLOT-MAP-OK-001 | 槽位保留样机 | GT06N | GPS Tracker | 860000000000001 | Concox | 3 | | `替换为已存在且未绑定的 ICCID_A` | `替换为已存在且未绑定的 ICCID_B` | |
验收要点:
- 任务结果中该行应导入成功。
- 设备绑定结果中,`ICCID_A``slot_position = 2`
- 设备绑定结果中,`ICCID_B``slot_position = 3`
## 样例 2填写槽位超出最大 SIM 槽数
预期该行导入失败失败原因为“卡槽填写超出最大SIM槽数”。
| virtual_no | device_name | device_model | device_type | imei | manufacturer | max_sim_slots | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|------------|-------------|--------------|-------------|------|--------------|---------------|---------|---------|---------|---------|
| SLOT-MAP-FAIL-001 | 越界槽位样机 | GT06N | GPS Tracker | 860000000000002 | Concox | 2 | | `替换为已存在且未绑定的 ICCID_C` | `替换为已存在且未绑定的 ICCID_D` | |
验收要点:
- 任务结果中该行应计入失败。
- 失败明细中的 `reason` 应为“卡槽填写超出最大SIM槽数”。
- 该设备不应创建任何绑定记录。
## 执行前检查
- 准备的 ICCID 必须已存在系统中。
- 准备的 ICCID 必须未绑定其他设备。
- 上传前确认文件扩展名为 `.xlsx`
## 结果核对建议
- 任务详情页核对成功数、失败数和失败原因。
- 设备详情页或绑定列表接口核对 `slot_position`
- 如需进一步确认,可直接查询 `tb_device_sim_binding.slot_position`

View File

@@ -48,7 +48,13 @@ type DeviceRow struct {
DeviceType string
MaxSimSlots int
Manufacturer string
ICCIDs []string
SlotICCIDs []DeviceSlotICCID
}
// DeviceSlotICCID 设备槽位与 ICCID 的映射
type DeviceSlotICCID struct {
SlotPosition int
ICCID string
}
var (
@@ -149,18 +155,21 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
row.Manufacturer = getCol(5)
maxSimSlotsStr := getCol(6)
row.ICCIDs = make([]string, 0, 4)
row.SlotICCIDs = make([]DeviceSlotICCID, 0, 4)
for j := 7; j <= 10; j++ {
iccid := getCol(j)
if iccid != "" {
row.ICCIDs = append(row.ICCIDs, iccid)
row.SlotICCIDs = append(row.SlotICCIDs, DeviceSlotICCID{
SlotPosition: j - 6,
ICCID: iccid,
})
}
}
// 整行目标列皆为空,视为空行跳过,不计入 total
if row.VirtualNo == "" && row.DeviceName == "" && row.DeviceModel == "" &&
row.DeviceType == "" && row.IMEI == "" && row.Manufacturer == "" &&
maxSimSlotsStr == "" && len(row.ICCIDs) == 0 {
maxSimSlotsStr == "" && len(row.SlotICCIDs) == 0 {
continue
}
@@ -192,7 +201,20 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
continue
}
for _, slotICCID := range row.SlotICCIDs {
if slotICCID.SlotPosition > row.MaxSimSlots {
parseResult.ParseErrors = append(parseResult.ParseErrors, CSVParseError{
Line: lineNum,
ICCID: slotICCID.ICCID,
Reason: "卡槽填写超出最大SIM槽数",
})
goto nextDeviceRow
}
}
parseResult.Rows = append(parseResult.Rows, row)
nextDeviceRow:
}
return parseResult, nil