# IoT Device - Delta Spec

## MODIFIED Requirements

### Requirement: 设备与 IoT 卡绑定关系

系统 SHALL 管理设备与 IoT 卡的绑定关系,一个设备可以绑定 1-4 张 IoT 卡。

**绑定规则**:
- 一个设备最多绑定 4 张 IoT 卡(由 `max_sim_slots` 字段控制)
- 一个 IoT 卡同一时间只能绑定一个设备
- 绑定时记录插槽位置(slot_position: 1, 2, 3, 4)
- 绑定时记录绑定时间和绑定状态(1-已绑定 2-已解绑)
- 绑定/解绑操作不改变 IoT 卡的 shop_id(所有权由分销操作管理,而非绑定操作)
- **新增**: 同一设备的同一插槽同一时间只能绑定一张卡(数据库唯一约束)

**中间表 tb_device_sim_binding**:
- `id`: 绑定记录 ID(主键,BIGINT)
- `device_id`: 设备 ID(BIGINT)
- `iot_card_id`: IoT 卡 ID(BIGINT)
- `slot_position`: 插槽位置(INT,1-4)
- `bind_status`: 绑定状态(INT,1-已绑定 2-已解绑)
- `bind_time`: 绑定时间(TIMESTAMP)
- `unbind_time`: 解绑时间(TIMESTAMP,可空)
- `created_at`: 创建时间(TIMESTAMP,自动填充)
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
- `deleted_at`: 软删除时间(TIMESTAMP,可空)
- `creator`: 创建人 ID(BIGINT)
- `updater`: 更新人 ID(BIGINT)

**数据库约束**:
- `idx_device_sim_bindings_active_card`: 唯一索引 (iot_card_id) WHERE bind_status = 1,防止同一张卡绑定到多个设备
- **新增** `idx_active_device_slot`: 唯一索引 (device_id, slot_position) WHERE bind_status = 1 AND deleted_at IS NULL,防止同一插槽绑定多张卡

**并发安全**:
- 系统 SHALL 在数据库层面通过唯一约束防止并发绑定导致的数据不一致
- 系统 SHALL 正确处理唯一约束冲突错误,返回友好的用户提示而非通用数据库错误

#### Scenario: 绑定 IoT 卡到设备

- **WHEN** 用户将 IoT 卡(ID 为 101)绑定到设备(ID 为 1001)的插槽 1
- **THEN** 系统创建绑定记录,`device_id` 为 1001,`iot_card_id` 为 101,`slot_position` 为 1,`bind_status` 为 1(已绑定),`bind_time` 为当前时间

#### Scenario: 解绑 IoT 卡

- **WHEN** 用户解绑设备的 IoT 卡(绑定记录 ID 为 10)
- **THEN** 系统将绑定记录的 `bind_status` 从 1(已绑定) 变更为 2(已解绑),`unbind_time` 记录解绑时间,IoT 卡的 `shop_id` 保持不变

#### Scenario: 并发绑定同一张卡到不同设备

- **WHEN** 两个请求同时尝试将同一张 IoT 卡(ID 为 101)绑定到不同设备
- **THEN** 第一个请求成功,第二个请求返回错误"该卡已绑定到其他设备"

#### Scenario: 并发绑定不同卡到同一设备插槽

- **WHEN** 两个请求同时尝试将不同 IoT 卡绑定到同一设备(ID 为 1001)的同一插槽(slot_position 为 1)
- **THEN** 第一个请求成功,第二个请求返回错误"该插槽已有绑定的卡"

---

### Requirement: 设备批量导入

系统 SHALL 支持批量导入设备数据,用于平台库存管理。

**导入字段**:
- 设备编号(必填)
- 设备名称(可选)
- 设备型号(可选)
- 设备类型(可选)
- 最大插槽数(可选,默认 4)
- 设备制造商(可选)
- 批次号(可选,由任务自动生成)
- **ICCID 1-4**(可选,用于绑定 IoT 卡)

**导入规则**:
- 设备编号必须唯一,重复编号将被跳过
- 导入的设备默认 `shop_id` 为 NULL(平台库存),状态为 1(在库)
- 导入成功后记录操作日志

**IoT 卡绑定规则**(新增):
- 系统 SHALL 校验 ICCID 对应的卡是否存在
- 系统 SHALL 校验卡是否已绑定到其他设备
- **新增**: 系统 SHALL 校验卡的归属权,只允许绑定平台库存的卡(shop_id = NULL)
- 如果卡已分配给店铺(shop_id != NULL),系统 SHALL 拒绝绑定并记录原因

**导入结果分类**(新增):
- **完全成功**: 设备创建且所有指定的卡都绑定成功
- **部分成功**: 设备创建但部分卡绑定失败(新增 warning 状态)
- **跳过**: 设备编号已存在
- **失败**: 设备创建失败或所有指定的卡都不可用

**导入任务模型扩展**(新增):
- `warning_count`: 警告数量(部分成功的设备数)
- `warning_items`: 警告记录详情(JSONB,记录哪些卡绑定失败及原因)

#### Scenario: 批量导入设备成功

- **WHEN** 平台上传包含 50 条设备数据的 CSV 文件
- **THEN** 系统创建 50 条设备记录,`shop_id` 为 NULL(平台库存),状态为 1(在库),返回导入成功消息

#### Scenario: 批量导入包含重复编号

- **WHEN** 平台上传的 CSV 文件中包含已存在的设备编号
- **THEN** 系统跳过重复编号的设备,记录到 skipped_items 并列出重复编号,其他有效设备正常导入

#### Scenario: 导入时绑定平台库存的卡

- **WHEN** CSV 行指定了 ICCID,且该卡为平台库存(shop_id = NULL)且未绑定其他设备
- **THEN** 系统创建设备并绑定该卡,记录为完全成功

#### Scenario: 导入时尝试绑定已分配给店铺的卡

- **WHEN** CSV 行指定了 ICCID,但该卡已分配给店铺(shop_id != NULL)
- **THEN** 系统创建设备但不绑定该卡,将该设备记录到 warning_items,原因为"ICCID-XXX 已分配给店铺,不能绑定到平台库存设备"

#### Scenario: 导入时部分卡绑定成功

- **WHEN** CSV 行指定了 4 张卡,其中 2 张为平台库存且未绑定,1 张已分配给店铺,1 张不存在
- **THEN** 系统创建设备并绑定 2 张有效的卡,将该设备记录到 warning_items,原因为"部分卡绑定失败: ICCID-001 已分配给店铺,不能绑定到平台库存设备; ICCID-002 不存在",success_count 和 warning_count 各加 1

#### Scenario: 导入时所有指定的卡都不可用

- **WHEN** CSV 行指定了 2 张卡,但都已绑定到其他设备
- **THEN** 系统不创建设备,将该行记录到 failed_items,原因为"所有指定的卡都不可用: ICCID-001 已绑定其他设备, ICCID-002 已绑定其他设备"

## ADDED Requirements

### Requirement: DeviceSimBinding 模型组织

系统 SHALL 将 DeviceSimBinding 模型定义在独立的文件中,遵循项目代码组织规范。

**文件位置**:
- 从: `internal/model/package.go`
- 到: `internal/model/device_sim_binding.go`

**模型内容**:
```go
// DeviceSimBinding 设备-IoT卡绑定关系模型
// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
type DeviceSimBinding struct {
    gorm.Model
    BaseModel    `gorm:"embedded"`
    DeviceID     uint       `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID"`
    IotCardID    uint       `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID"`
    SlotPosition int        `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)"`
    BindStatus   int        `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑"`
    BindTime     *time.Time `gorm:"column:bind_time;comment:绑定时间"`
    UnbindTime   *time.Time `gorm:"column:unbind_time;comment:解绑时间"`
}

func (DeviceSimBinding) TableName() string {
    return "tb_device_sim_binding"
}
```

#### Scenario: 模型文件独立

- **WHEN** 开发者需要查找或修改 DeviceSimBinding 模型
- **THEN** 模型定义位于 `internal/model/device_sim_binding.go` 文件中,而非混杂在 `package.go` 中
