# 需求08：设备批量分配代理或套餐系列（Excel导入）

> 状态：原需求独立稿；最终口径以标准评审稿为准。
> 模板：前端静态资源，后端仅负责上传校验和异步处理。

---

## 背景

设备号不连续，无法通过号段批量分配。需要通过上传 Excel 表（表头：设备号）完成两项独立操作：

1. 批量分配设备给代理。
2. 批量分配设备给套餐系列。

两项操作可以复用解析、异步任务和失败明细基础设施，但**一个任务只能执行一个业务命令**，不能在一次提交中同时修改 `shop_id` 和 `series_id`。

`Device` 表已有 `shop_id *uint` 和 `series_id *uint` 字段，分配即更新这两个字段。

```mermaid
sequenceDiagram
    actor User as 平台员工
    participant Web as 设备管理前端
    participant API as BatchAllocation API
    participant DB as PostgreSQL
    participant Worker as Asynq Worker

    User->>Web: 选择“分配代理”或“分配套餐系列”并上传 Excel
    Web->>API: POST /api/admin/devices/batch-assign-shop 或 batch-assign-series
    API->>DB: 创建任务并保存上传文件引用
    API-->>Web: task_id
    API->>Worker: 入队处理任务
    Worker->>DB: 按设备号批量查询并条件更新
    Worker->>DB: 写成功数、失败数和失败明细
    Web->>API: 轮询任务详情
    API-->>Web: 处理结果
```

---

## 数据库变更

新建批量分配任务表（不复用 `DeviceImportTask`，业务语义不同）：

```sql
CREATE TABLE tb_device_batch_allocation_task (
    id            BIGSERIAL PRIMARY KEY,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at    TIMESTAMPTZ,
    creator       BIGINT NOT NULL DEFAULT 0,
    updater       BIGINT NOT NULL DEFAULT 0,
    task_no       VARCHAR(30) NOT NULL,
    source_file_key VARCHAR(500) NOT NULL,
    operation_type VARCHAR(30) NOT NULL,
    target_id     BIGINT NOT NULL,
    operator_id   BIGINT NOT NULL,
    total_count   INT NOT NULL DEFAULT 0,
    success_count INT NOT NULL DEFAULT 0,
    fail_count    INT NOT NULL DEFAULT 0,
    status        INT NOT NULL DEFAULT 1,
    failed_items  JSONB,
    started_at    TIMESTAMPTZ,
    completed_at  TIMESTAMPTZ
);

CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL;
CREATE INDEX idx_device_batch_allocation_task_operation ON tb_device_batch_allocation_task(operation_type, status, created_at DESC);
```

状态常量：1=处理中，2=已完成，3=失败。

`operation_type`：`assign_shop`（分配代理）或 `assign_series`（分配套餐系列）。`target_id` 随操作类型分别指向代理店铺或套餐系列；不建立数据库外键。

失败明细存 JSONB（`failed_items`），失败条数有限，无需单独明细表。

---

## Model（`internal/model/device_batch_allocation_task.go`）

```go
// DeviceBatchAllocationTask 设备批量分配任务模型
type DeviceBatchAllocationTask struct {
    gorm.Model
    BaseModel    `gorm:"embedded"`
    TaskNo       string            `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"`
    SourceFileKey string            `gorm:"column:source_file_key;type:varchar(500);not null;comment:待处理Excel对象存储Key" json:"source_file_key"`
    OperationType string            `gorm:"column:operation_type;type:varchar(30);not null;comment:操作类型 assign_shop=分配代理 assign_series=分配套餐系列" json:"operation_type"`
    TargetID     uint              `gorm:"column:target_id;not null;comment:操作目标ID（代理店铺或套餐系列）" json:"target_id"`
    OperatorID   uint              `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"`
    TotalCount   int               `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"`
    SuccessCount int               `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"`
    FailCount    int               `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"`
    Status       int               `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"`
    FailedItems  ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"`
    StartedAt    *time.Time        `gorm:"column:started_at" json:"started_at"`
    CompletedAt  *time.Time        `gorm:"column:completed_at" json:"completed_at"`
}

func (DeviceBatchAllocationTask) TableName() string {
    return "tb_device_batch_allocation_task"
}
```

> `ImportResultItems` 复用 `internal/model/device_import_task.go` 中已定义的类型。

---

## API 设计

### 1. 前端静态 Excel 模板

模板由前端项目作为静态资源随版本发布，后端不提供模板下载接口。

- 文件建议命名：`设备批量分配模板-v1.xlsx`
- 表头：`设备号`（一列）
- 前端“下载模板”按钮直接下载静态文件。
- 后端只校验上传文件的表头、行数和内容，不读取或生成前端模板文件。

模板字段发生变化时，前后端必须在同一发布批次升级；旧模板仍可能被用户保存在本地，因此后端错误需要明确指出缺失或未知表头。

### 2. 上传并提交

```
POST /api/admin/devices/batch-assign-shop
Content-Type: multipart/form-data

字段：
  shop_id:  123   （必填，分配给哪个代理）
  file: <Excel文件>
```

```text
POST /api/admin/devices/batch-assign-series
Content-Type: multipart/form-data

字段：
  series_id: 45   （必填，分配给哪个套餐系列）
  file: <Excel文件>
```

两个接口内部创建同一类任务表记录，但分别写入 `operation_type=assign_shop|assign_series`。请求中不接受另一个目标字段，避免前端通过隐藏参数把两个业务动作合并。

**处理逻辑（Asynq 异步）**：

1. API 将上传文件保存到对象存储，把 `source_file_key` 写入任务；Worker 从对象存储下载并解析 Excel
2. 对设备号去重后分批查询 `tb_device`（按 `virtual_no` 或 `imei`），禁止逐行查询
3. `assign_shop` 仅按状态/归属条件批量更新 `shop_id`；`assign_series` 仅更新 `series_id`。
4. 未找到的：记录失败原因"设备号不存在"。
5. `assign_shop` 遇到已分配给其他代理的设备：记录"该设备已分配，请先回收"；`assign_series` 不改变代理归属。
6. Worker 重试时只处理仍未满足目标结果的设备；已经分配到同一目标的记录按幂等成功处理。

临时本地文件路径不能进入 Asynq 载荷。任务结束后源文件按统一对象存储生命周期清理，清理失败不影响任务结果。

限制：单文件最大 10MB、去重前最多 1000 行、Worker 每批最多 200 条、任务最多保留 1000 条失败明细。超过限制在 API 或解析阶段返回明确错误，不创建无限时长任务。

### 3. 查询任务状态

```
GET /api/admin/devices/batch-allocation/{task_id}
```

响应：

```json
{
  "task_no": "DBA20240101001",
  "operation_type": "assign_shop",
  "target_id": 123,
  "status": 2,
  "status_name": "已完成",
  "total_count": 100,
  "success_count": 95,
  "fail_count": 5,
  "failed_items": [
    { "line": 3, "virtual_no": "12345", "reason": "设备号不存在" },
    { "line": 7, "virtual_no": "67890", "reason": "该设备已分配，请先回收" }
  ],
  "started_at": "2024-01-01T10:00:00Z",
  "completed_at": "2024-01-01T10:00:05Z"
}
```

---

## 前端对接

### 入口

设备管理页 > 批量操作：

- "批量分配代理"
- "批量分配套餐系列"

### 操作流程

1. 点击其中一个批量操作入口。
2. "批量分配代理"弹框只显示代理选择器和 Excel 上传区域；"批量分配套餐系列"弹框只显示套餐系列选择器和 Excel 上传区域。
3. 上传后点击"提交"，分别调用 `POST /api/admin/devices/batch-assign-shop` 或 `POST /api/admin/devices/batch-assign-series`。
4. 提示"任务提交成功，正在处理..."
5. 轮询 `GET /api/admin/devices/batch-allocation/{task_id}` 直到 `status != 1`
6. 展示结果：成功X条，失败X条，失败明细在页面展示

### Excel 规范

- 表头：`设备号`
- 每行一个设备号（支持虚拟号或IMEI）
