# API v2 Mission 任务模块接口文档

本文档面向前端，描述 `mission` 中立任务模块的 API。

## 基本约定

- Base URL: `/api/v2`
- 认证：所有接口都需要登录。
- 人员身份：后端使用 `request.user.employee` 作为当前员工身份。
- 多商户隔离：所有任务、任务分类、参与者、回应都只允许访问当前员工所属商户的数据。
- 删除能力：不提供任务删除接口；需要结束业务时使用 `cancel` 取消任务。
- 状态字段：普通创建/更新接口不允许修改状态字段，状态变化统一走独立接口。

普通 CRUD 禁止提交这些字段：

| 字段 | 说明 |
|------|------|
| `is_urgent` | 是否紧急，使用 `set-urgent` 接口修改 |
| `is_completed` | 是否完成，由结束回应或 reopen/reject 流程维护 |
| `is_cancelled` | 是否取消，使用 `cancel` 接口修改 |
| `cancelled_by` | 取消人，由后端写入 |
| `cancelled_at` | 取消时间，由后端写入 |
| `rejected_by` | 撤销人，由后端写入 |
| `rejected_at` | 撤销时间，由后端写入 |

说明：

- 本次新增的“未回复提醒配置字段”不属于状态字段，允许通过普通创建/更新接口维护

## content_type 说明

`content_type` 和 `content_id` 是可选的"关联业务对象"字段，用于把任务挂靠到系统中的某个具体业务单据或对象上。

### content_type 是什么

`content_type` 是一个整数，对应后端数据库 `django_content_type` 表的主键（`id`）。  
该表记录了系统中所有 Django 模型的元信息，每一行代表一个模型，格式为 `app_label` + `model`。

**前端不应硬编码 content_type 整数 ID**，因为这个 ID 在不同部署环境中可能不同。  
正确做法：调用 `/api/v2/content-types/` 接口（见下方"查询可用 content_type"一节）获取当前环境的实际 ID。

### content_type_label 字段

响应体中的 `content_type_label` 字段是人类可读的模型标识，格式为 `"app_label.model"`，例如：

- `"printing.plateorder"` — 开版单
- `"printing.printingjob"` — 印刷任务
- `"business.purchaseorder"` — 采购单

该字段**只读**，用于前端展示或调试，不作为提交 content_type 时的值。

### 当前系统中可关联的主要业务对象

| `content_type_label` | 中文说明 |
|---|---|
| `printing.plateorder` | 开版单 |
| `printing.printingorder` | 印刷单 |
| `printing.printingjob` | 印刷任务 |
| `business.purchaseorder` | 采购单 |
| `business.presalesorder` | 预销售单 |
| `business.salesorder` | 销售单 |
| `business.prepurchaseorder` | 预采购单 |
| `business.purchasereturnorder` | 采购退货单 |
| `business.salesreturnorder` | 销售退货单 |
| `business.paymentorder` | 付款单 |
| `business.receiptorder` | 收款单 |
| `stock.transferorder` | 调拨单 |
| `shipment.shipment` | 发货单 |
| `stateflow.process` | 工艺流程实例 |

> 上述列表是"有意义的"业务关联对象；系统本身不限制可关联的模型类型，技术上任何有效 ContentType ID 都被接受。

### 查询可用 content_type

- URL: `/api/v2/content-types/`
- Method: `GET`

响应示例：

```json
[
  {"id": 7, "app_label": "business", "model": "paymentorder", "label": "business.paymentorder", "name": "付款单"},
  {"id": 8, "app_label": "business", "model": "presalesorder", "label": "business.presalesorder", "name": "预销售单"},
  ...
]
```

前端应在运行时调用此接口获取 `id`，不应硬编码，因为不同部署环境的 ID 可能不同。

## 数据结构

### MissionCategory

```json
{
  "id": 1,
  "merchant": 10,
  "name": "通用",
  "created_at": "2026-04-10T12:00:00+08:00",
  "updated_at": "2026-04-10T12:00:00+08:00"
}
```

### Mission

```json
{
  "id": 1,
  "merchant": 10,
  "description": "跟进客户问题",
  "category": 1,
  "category_name": "通用",
  "is_urgent": false,
  "is_completed": false,
  "is_cancelled": false,
  "notify_if_unreplied": false,
  "unreplied_notify_interval_minutes": null,
  "unreplied_notify_max_count": 5,
  "unreplied_notify_sent_count": 0,
  "unreplied_last_notified_at": null,
  "cancelled_at": null,
  "creator": {
    "id": 20,
    "name": "张三",
    "merchant_id": 10
  },
  "cancelled_by": null,
  "participants": [
    {
      "id": 21,
      "name": "李四",
      "merchant_id": 10
    }
  ],
  "content_type": null,
  "content_type_label": null,
  "content_id": null,
  "has_ending_reply": false,
  "can_reply": true,
  "created_at": "2026-04-10T12:00:00+08:00",
  "updated_at": "2026-04-10T12:00:00+08:00"
}
```

### MissionReply

```json
{
  "id": 100,
  "mission": 1,
  "merchant": 10,
  "responder": {
    "id": 20,
    "name": "张三",
    "merchant_id": 10
  },
  "content": "已处理",
  "replied_at": "2026-04-10T12:10:00+08:00",
  "ends_task": true,
  "is_rejected": false,
  "rejected_by": null,
  "rejected_at": null,
  "created_at": "2026-04-10T12:10:00+08:00",
  "updated_at": "2026-04-10T12:10:00+08:00"
}
```

## 任务分类列表

- URL: `/api/v2/mission-categories/`
- Method: `GET`

响应：`MissionCategory[]`

## 创建任务分类

- URL: `/api/v2/mission-categories/`
- Method: `POST`

请求参数：

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | string | 是 | 分类名称，同商户下唯一 |

请求示例：

```json
{
  "name": "售后"
}
```

成功响应：`201 Created`，返回 `MissionCategory`

## 任务分类详情

- URL: `/api/v2/mission-categories/<category_id>/`
- Method: `GET`

成功响应：`MissionCategory`

## 更新任务分类

- URL: `/api/v2/mission-categories/<category_id>/`
- Method: `PATCH`

允许更新：

| 参数 | 类型 | 说明 |
|------|------|------|
| `name` | string | 分类名称，同商户下唯一 |

成功响应：`MissionCategory`

## 删除任务分类

- URL: `/api/v2/mission-categories/<category_id>/`
- Method: `DELETE`

说明：

- 如果该分类已经被任务使用，则删除会被拒绝，返回 `400`

成功响应：`204 No Content`

## 任务列表

- URL: `/api/v2/missions/`
- Method: `GET`

查询参数：

| 参数 | 类型 | 说明 |
|------|------|------|
| `category` | int | 任务分类 ID |
| `is_urgent` | boolean | `true` / `false` |
| `is_completed` | boolean | `true` / `false` |
| `is_cancelled` | boolean | `true` / `false` |
| `content_type` | int | Django ContentType ID |
| `content_id` | int | 关联业务对象 ID |

响应：`Mission[]`

## 创建任务

- URL: `/api/v2/missions/`
- Method: `POST`

请求参数：

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `description` | string | 是 | 任务描述 |
| `category` | int | 否 | 任务分类 ID；不传时默认使用当前商户下名称为“通用”的分类，不存在则自动创建 |
| `content_type` | int/null | 否 | Django ContentType ID；必须与 `content_id` 同时提供或同时省略 |
| `content_id` | int/null | 否 | 关联业务对象 ID；必须与 `content_type` 同时提供或同时省略 |
| `notify_if_unreplied` | boolean | 否 | 是否开启“未回复持续提醒”，默认 `false` |
| `unreplied_notify_interval_minutes` | int/null | 否 | 未回复提醒间隔（分钟）；开启未回复提醒时必填 |
| `unreplied_notify_max_count` | int | 否 | 最大提醒次数，默认 `5` |
| `participant_ids` | int[] | 否 | 参与者员工 ID 列表，必须属于当前商户 |

说明：

- `creator`、`merchant` 由当前登录用户的 employee 自动写入
- `category_name` 为只读字段，由后端根据分类表返回
- `is_urgent`、`is_completed`、`is_cancelled` 均按默认值创建，不接受请求参数
- 若关联对象存在 `merchant_id` 字段，后端会校验它必须属于当前商户
- 若 `notify_if_unreplied=true`，则必须同时传入 `unreplied_notify_interval_minutes`
- `unreplied_notify_sent_count` 与 `unreplied_last_notified_at` 为只读运行时字段，由后端维护

请求示例：

```json
{
  "description": "跟进客户问题",
  "category": 1,
  "notify_if_unreplied": true,
  "unreplied_notify_interval_minutes": 30,
  "unreplied_notify_max_count": 5,
  "participant_ids": [21, 22]
}
```

成功响应：`201 Created`，返回 `Mission`

## 任务详情

- URL: `/api/v2/missions/<mission_id>/`
- Method: `GET`

成功响应：`Mission`

跨商户访问返回 `404`

## 更新任务

- URL: `/api/v2/missions/<mission_id>/`
- Method: `PATCH`

允许更新：

| 参数 | 类型 | 说明 |
|------|------|------|
| `description` | string | 任务描述 |
| `category` | int | 任务分类 ID |
| `content_type` | int/null | 关联对象类型；必须与 `content_id` 同时提供 |
| `content_id` | int/null | 关联对象 ID；必须与 `content_type` 同时提供 |
| `notify_if_unreplied` | boolean | 是否开启“未回复持续提醒” |
| `unreplied_notify_interval_minutes` | int/null | 未回复提醒间隔（分钟） |
| `unreplied_notify_max_count` | int | 最大提醒次数 |
| `participant_ids` | int[] | 重置参与者列表 |

禁止更新状态字段，见“基本约定”

补充说明：

- 更新 `notify_if_unreplied`、`unreplied_notify_interval_minutes` 时，后端会重置当前任务的未回复提醒计数与上次提醒时间
- 更新 `unreplied_notify_max_count` 不会重置已提醒次数

成功响应：`Mission`

## 删除任务

- URL: `/api/v2/missions/<mission_id>/`
- Method: `DELETE`

当前不提供删除能力，固定返回：

```json
{
  "detail": "Mission 删除接口未提供，请使用 cancel 接口取消任务"
}
```

HTTP 状态码：`405 Method Not Allowed`

## 获取任务回应列表

- URL: `/api/v2/missions/<mission_id>/replies/`
- Method: `GET`

查询参数：

| 参数 | 类型 | 说明 |
|------|------|------|
| `ends_task` | boolean | `true` / `false`，筛选是否为结束回应 |
| `is_rejected` | boolean | `true` / `false`，筛选是否已被撤销 |

响应：`MissionReply[]`，按 `replied_at` 升序排列

## 创建任务回应

- URL: `/api/v2/missions/<mission_id>/replies/`
- Method: `POST`

请求参数：

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `content` | string | 是 | 回应内容 |
| `ends_task` | boolean | 否 | 是否结束任务，默认 `false` |

说明：

- `responder` 使用当前登录用户的 employee
- 如果 `ends_task=true`，后端会同步设置 `Mission.is_completed=true`
- 已取消任务、或已有有效结束回应的任务不允许继续回应

成功响应：`201 Created`，返回 `MissionReply`

## 重新打开任务

- URL: `/api/v2/missions/<mission_id>/reopen/`
- Method: `POST`
- 额外权限：`mission.reopen_mission`

请求体可为空：

```json
{}
```

前置条件：

- 当前用户必须具备 `mission.reopen_mission`
- 任务未取消
- 任务已完成
- 任务存在有效的 `ends_task=true` 回应

执行效果：

- `Mission.is_completed=false`
- 相关结束回应被标记为 `ends_task=false`
- 相关结束回应记录 `is_rejected=true`、`rejected_by=当前员工`、`rejected_at=当前时间`

成功响应：`Mission`

无权限响应：`403 Forbidden`

## 取消任务

- URL: `/api/v2/missions/<mission_id>/cancel/`
- Method: `POST`

请求体可为空：

```json
{}
```

执行效果：

- `Mission.is_cancelled=true`
- `Mission.cancelled_by=当前员工`
- `Mission.cancelled_at=当前时间`
- 不强行修改 `Mission.is_completed`
- 后端会停止当前任务后续的未回复提醒，并保留任务上的提醒历史字段供查看

成功响应：`Mission`

## 设置紧急状态

- URL: `/api/v2/missions/<mission_id>/set-urgent/`
- Method: `POST`

请求参数：

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `is_urgent` | boolean | 是 | 新的紧急状态 |

请求示例：

```json
{
  "is_urgent": true
}
```

成功响应：`Mission`

## 撤销任务回应

- URL: `/api/v2/mission-replies/<reply_id>/reject/`
- Method: `POST`
- 额外权限：`mission.reject_mission_reply`

请求体可为空：

```json
{}
```

执行效果：

- 当前回应标记为 `is_rejected=true`
- 当前回应的 `rejected_by`、`rejected_at` 由后端写入
- 如果当前回应原本是唯一有效的结束回应，则对应任务会被重新置为未完成
- 如果任务重新回到“没有任何有效回复”的状态，后端会重置未回复提醒计数，并从新的空窗期重新开始计算后续提醒

成功响应：`MissionReply`

无权限响应：`403 Forbidden`

## 未回复提醒规则

当任务开启 `notify_if_unreplied=true` 时，系统会通过每分钟一次的后台定时任务检查是否需要发送“未回复提醒”。

判定规则：

1. 任务开启了未回复提醒
2. 任务未完成
3. 任务未取消
4. 任务当前没有任何有效回复（`is_rejected=false` 的回复）
5. `unreplied_notify_sent_count < unreplied_notify_max_count`
6. 到达提醒时间：
  - 从未提醒过：`created_at + unreplied_notify_interval_minutes`
  - 已提醒过：`unreplied_last_notified_at + unreplied_notify_interval_minutes`

发送成功后：

- `unreplied_notify_sent_count` 自增 1
- `unreplied_last_notified_at` 更新为本次成功发送时间

收到有效回复后：

- 当前未回复提醒周期会被停止
- `unreplied_notify_sent_count` 与 `unreplied_last_notified_at` 会被重置

如果后续因为撤销回复或 reopen 重新回到“无有效回复”状态：

- 系统会把该任务视为一个新的未回复周期重新开始计时
