diff --git a/docs/excel-import-frontend-guide.md b/docs/excel-import-frontend-guide.md index da0d9fd..d5d2b3f 100644 --- a/docs/excel-import-frontend-guide.md +++ b/docs/excel-import-frontend-guide.md @@ -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` 必须留空;否则该行应导入失败 ## 模板下载功能实现 diff --git a/internal/routes/device.go b/internal/routes/device.go index 4853207..a339960 100644 --- a/internal/routes/device.go +++ b/internal/routes/device.go @@ -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), diff --git a/internal/task/device_import.go b/internal/task/device_import.go index 18e4f0a..a7992cd 100644 --- a/internal/task/device_import.go +++ b/internal/task/device_import.go @@ -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 } } diff --git a/openspec/changes/fix-device-import-slot-mapping/.openspec.yaml b/openspec/changes/fix-device-import-slot-mapping/.openspec.yaml new file mode 100644 index 0000000..12e66c2 --- /dev/null +++ b/openspec/changes/fix-device-import-slot-mapping/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-30 diff --git a/openspec/changes/fix-device-import-slot-mapping/design.md b/openspec/changes/fix-device-import-slot-mapping/design.md new file mode 100644 index 0000000..775585b --- /dev/null +++ b/openspec/changes/fix-device-import-slot-mapping/design.md @@ -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”;这会影响使用体验,但不阻塞后端修复。 diff --git a/openspec/changes/fix-device-import-slot-mapping/proposal.md b/openspec/changes/fix-device-import-slot-mapping/proposal.md new file mode 100644 index 0000000..eb9ce58 --- /dev/null +++ b/openspec/changes/fix-device-import-slot-mapping/proposal.md @@ -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,仅修正既有导入行为 diff --git a/openspec/changes/fix-device-import-slot-mapping/specs/device-import/spec.md b/openspec/changes/fix-device-import-slot-mapping/specs/device-import/spec.md new file mode 100644 index 0000000..2a22334 --- /dev/null +++ b/openspec/changes/fix-device-import-slot-mapping/specs/device-import/spec.md @@ -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` 字段为对应行号 diff --git a/openspec/changes/fix-device-import-slot-mapping/tasks.md b/openspec/changes/fix-device-import-slot-mapping/tasks.md new file mode 100644 index 0000000..9cd06c0 --- /dev/null +++ b/openspec/changes/fix-device-import-slot-mapping/tasks.md @@ -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` 表示固定槽位而非连续顺序。 diff --git a/openspec/changes/fix-device-import-slot-mapping/手工验收样例.md b/openspec/changes/fix-device-import-slot-mapping/手工验收样例.md new file mode 100644 index 0000000..8fd5703 --- /dev/null +++ b/openspec/changes/fix-device-import-slot-mapping/手工验收样例.md @@ -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`。 diff --git a/pkg/utils/excel.go b/pkg/utils/excel.go index 3c0c720..aef6e31 100644 --- a/pkg/utils/excel.go +++ b/pkg/utils/excel.go @@ -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