设备导入卡槽不对的问题
This commit is contained in:
@@ -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` 必须留空;否则该行应导入失败
|
||||
|
||||
## 模板下载功能实现
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-30
|
||||
111
openspec/changes/fix-device-import-slot-mapping/design.md
Normal file
111
openspec/changes/fix-device-import-slot-mapping/design.md
Normal 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”;这会影响使用体验,但不阻塞后端修复。
|
||||
32
openspec/changes/fix-device-import-slot-mapping/proposal.md
Normal file
32
openspec/changes/fix-device-import-slot-mapping/proposal.md
Normal 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,仅修正既有导入行为
|
||||
@@ -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` 字段为对应行号
|
||||
17
openspec/changes/fix-device-import-slot-mapping/tasks.md
Normal file
17
openspec/changes/fix-device-import-slot-mapping/tasks.md
Normal 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` 表示固定槽位而非连续顺序。
|
||||
50
openspec/changes/fix-device-import-slot-mapping/手工验收样例.md
Normal file
50
openspec/changes/fix-device-import-slot-mapping/手工验收样例.md
Normal 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`。
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user