1
0
forked from erp-dev/erp

feat: mission replied calling task

This commit is contained in:
2026-04-17 23:26:33 +08:00
parent e109c00837
commit 8b1afc64c5
22 changed files with 1056 additions and 95 deletions

View File

@@ -0,0 +1,114 @@
# Admin 说明:任务未回复提醒
本文档面向后台管理员,说明如何启用和排查“任务未回复提醒”。
## 这个功能是什么
当一个任务开启“未回复提醒”后,如果在设定时间内一直没有任何有效回复,系统会按该任务设置的间隔持续发送提醒。
这里的“有效回复”指:
- 存在 `MissionReply`
- 且该回复没有被撤销(`is_rejected = false`
如果任务已有有效回复,就不会再继续发“未回复提醒”。
## 这项功能由哪两部分共同决定
要真正收到未回复提醒,必须同时满足:
1. 任务对象本身开启了未回复提醒
2. notifier 后台中配置了 `mission.unreplied` 的通知路由
缺任何一边都不会发通知。
## 任务侧可配置项
任务对象现在支持这些字段:
- `notify_if_unreplied`:是否开启未回复提醒
- `unreplied_notify_interval_minutes`:提醒间隔(分钟)
- `unreplied_notify_max_count`:最大提醒次数,默认 `5`
- `unreplied_notify_sent_count`:已发送次数,由系统自动维护
- `unreplied_last_notified_at`:上次发送时间,由系统自动维护
管理员通常只需要关注前三个字段。
## notifier 后台应如何配置
### 第一步:创建通知器 Notifier
建议配置如下:
- `name`:任务未回复提醒-管理群
- `channel``wecom_webhook`
- `template_key``mission_unreplied`
- `is_enabled`:勾选
- `config.key`:企业微信机器人 key
### 第二步:创建通知路由 NotifierRoute
建议配置如下:
- `event_key``mission.unreplied`
- `mission_category`:可留空,也可以指定分类
- `is_enabled`:勾选
## 通知什么时候会发生
系统后台有一个每分钟执行一次的定时任务,会去检查哪些任务需要发送未回复提醒。
只有任务同时满足以下条件才会提醒:
1. 开启了未回复提醒
2. 未完成
3. 未取消
4. 当前没有任何有效回复
5. 已提醒次数还没达到最大提醒次数
6. 到达了当前提醒时间
提醒时间计算规则:
- 如果从未提醒过:`创建时间 + 间隔分钟数`
- 如果提醒过:`上次提醒时间 + 间隔分钟数`
## 收到有效回复后会怎么样
一旦任务出现有效回复:
- 当前未回复提醒周期会停止
- 已提醒次数与上次提醒时间会被重置
如果该回复后来被撤销,或者任务 reopen 后重新进入“无有效回复”状态:
- 系统会重新开始一个新的未回复提醒周期
## 管理员排查清单
如果任务没有收到未回复提醒,请按下面顺序检查:
1. 任务是否开启了 `notify_if_unreplied`
2. 任务是否填写了 `unreplied_notify_interval_minutes`
3. 任务是否已经完成或取消
4. 任务是否已经有有效回复
5. `unreplied_notify_sent_count` 是否已经达到 `unreplied_notify_max_count`
6. notifier 是否存在 `mission.unreplied` 路由
7. 路由是否启用
8. 对应的 Notifier 是否启用
9. `template_key` 是否配置为 `mission_unreplied`
10. Celery worker 和 Celery beat 是否在运行
## 给 admin 可直接复制的简版说明
```md
任务未回复提醒已上线。
要让任务自动提醒,必须同时满足两件事:
1. 任务本身开启了未回复提醒,并设置了提醒间隔和最大提醒次数。
2. notifier 后台里配置了 `mission.unreplied` 对应的通知器和通知路由。
系统会每分钟扫描一次任务。只有当任务未完成、未取消、当前没有有效回复、且没超过最大提醒次数时,才会继续发送提醒。
一旦任务收到有效回复,未回复提醒会自动停止;如果回复后来被撤销,系统会重新开始新的未回复提醒周期。
```

View File

@@ -23,6 +23,10 @@
| `rejected_by` | 撤销人,由后端写入 |
| `rejected_at` | 撤销时间,由后端写入 |
说明:
- 本次新增的“未回复提醒配置字段”不属于状态字段,允许通过普通创建/更新接口维护
## content_type 说明
`content_type``content_id` 是可选的"关联业务对象"字段,用于把任务挂靠到系统中的某个具体业务单据或对象上。
@@ -109,6 +113,11 @@
"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,
@@ -246,6 +255,9 @@
| `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 列表,必须属于当前商户 |
说明:
@@ -254,6 +266,8 @@
- `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` 为只读运行时字段,由后端维护
请求示例:
@@ -261,6 +275,9 @@
{
"description": "跟进客户问题",
"category": 1,
"notify_if_unreplied": true,
"unreplied_notify_interval_minutes": 30,
"unreplied_notify_max_count": 5,
"participant_ids": [21, 22]
}
```
@@ -289,10 +306,18 @@
| `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`
## 删除任务
@@ -390,6 +415,7 @@ HTTP 状态码:`405 Method Not Allowed`
- `Mission.cancelled_by=当前员工`
- `Mission.cancelled_at=当前时间`
- 不强行修改 `Mission.is_completed`
- 后端会停止当前任务后续的未回复提醒,并保留任务上的提醒历史字段供查看
成功响应:`Mission`
@@ -431,7 +457,37 @@ HTTP 状态码:`405 Method Not Allowed`
- 当前回应标记为 `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 重新回到“无有效回复”状态:
- 系统会把该任务视为一个新的未回复周期重新开始计时

View File

@@ -21,6 +21,7 @@
- `mission.created`
- `mission.replied`
- `mission.completed`
- `mission.unreplied`
- `mission.reply_rejected`
- `mission.reopened`
- `mission.cancelled`
@@ -144,6 +145,7 @@
- `mission.created`
- `mission.completed`
- `mission.unreplied`
### 6.4 mission_category
@@ -254,6 +256,7 @@
- `mission_created`
- `mission_replied`
- `mission_completed`
- `mission_unreplied`
- `mission_reply_rejected`
- `mission_reopened`
- `mission_cancelled`
@@ -261,7 +264,52 @@
管理人员通常只需要填 `template_key`,不需要改代码。
如果后续要新增模板内容或调整文案,需要由开发人员修改模板文件。
## 10. 如何停用
## 10. 未回复提醒的 admin 配置要点
`mission.unreplied` 和其它事件不同,它不是在某个瞬时动作发生时触发,而是由后台每分钟扫描一次“仍未回复的任务”后触发。
这意味着 admin 需要同时确认两件事:
1. 任务本身开启了未回复提醒
2. 通知系统中存在 `mission.unreplied` 对应的 `NotifierRoute`
如果只配置了路由,但任务没有开启提醒,则不会发送。
如果任务开启了提醒,但没有配置 `mission.unreplied` 路由,也不会发送到任何群。
### 10.1 推荐配置方式
1. 创建一个 `Notifier`
- `name = 任务未回复提醒-管理群`
- `channel = wecom_webhook`
- `template_key = mission_unreplied`
- `is_enabled = True`
2. 创建一条 `NotifierRoute`
- `event_key = mission.unreplied`
- `mission_category = 留空` 或选择具体任务分类
- `is_enabled = True`
### 10.2 什么时候会持续提醒
只有满足以下条件才会持续发送 `mission.unreplied`
1. 任务开启了“未回复提醒”
2. 任务还没完成
3. 任务还没取消
4. 当前没有任何有效回复
5. 没超过任务自身设置的最大提醒次数
### 10.3 为什么任务开启了提醒,但还是没收到群通知
优先检查:
1. 是否已配置 `mission.unreplied` 的路由
2. 该路由是否启用
3. 对应的 `Notifier` 是否启用
4. `template_key` 是否写成 `mission_unreplied`
5. Celery worker / beat 是否都在运行
## 11. 如何停用
### 停用整个通知器
@@ -290,7 +338,7 @@
2. 取消勾选 `is_enabled`
3. 保存
## 11. 常见问题
## 12. 常见问题
### 11.1 为什么事件发生了,但没有收到通知
@@ -322,7 +370,7 @@
不会。
系统会自动去重,并优先使用更具体的分类路由。
## 12. 管理建议
## 13. 管理建议
- 先建 `Notifier`,再建 `NotifierRoute`
- 通知器名称中写清楚目标群
@@ -330,7 +378,7 @@
- 先停用再删除
- 先配置一条路由做验证,再批量扩展
## 13. 最简操作结论
## 14. 最简操作结论
如果你只想快速配置一条通知,记住这 6 个关键点就够了:

View File

@@ -41,49 +41,27 @@
### 3.1 Notifier
`Notifier` 负责“怎么发”:
- `merchant`
- `name`
- `channel`
- `template_key`
- `is_enabled`
- `config`
- `description`
当前 `Notifier` 已不再直接持有 `event_key`
### 3.2 NotifierRoute
`NotifierRoute` 负责“何时发、发给谁”:
- `merchant`
- `notifier`
- `event_key`
- `mission_category`
- `is_enabled`
- `description`
其中:
- `mission_category = null` 表示该事件的通配路由
- `mission_category != null` 表示任务分类专用路由
### 3.3 约束设计
当前约束:
- `Notifier` 在同商户下 `name` 唯一
- `NotifierRoute` 在同一 `notifier + event_key + mission_category` 下唯一
- `NotifierRoute` 额外限制同一 `notifier + event_key` 只能有一条通配路由
## 4. 路由匹配规则
当前 `dispatch_notification_event(...)` 的匹配规则为:
1. 先按 `merchant + event_key + route.is_enabled=True + notifier.is_enabled=True` 查路由
2. 如果 payload 中带有 `category_id`
- 匹配该分类的专用路由
- 也允许匹配通配路由
3. 如果 payload 中没有 `category_id`
- 只匹配通配路由
@@ -91,11 +69,6 @@
- 只保留一条
- 优先保留专用路由
这样可以同时满足:
- 分类专用通知
- 默认兜底通知
- 多群并发通知
- 同一通知器不重复发送
## 5. 当前调用链
@@ -103,32 +76,14 @@
通知链路如下:
1. `mission.services` 在事务提交后发 signal
2. `mission.handlers` 构造 payload
3. payload 中已包含 `category_id``category_name`
4. handler 调用 `enqueue_notification_event(...)`
5. Celery task 调用 `dispatch_notification_event(...)`
6. notifier 根据 route 匹配命中的 `Notifier`
7. 渲染模板并调用 backend 发送
8. 记录路由日志和发送日志
## 6. 已接入的事件
当前 `mission` 已接入:
- `mission.created`
- `mission.replied`
- `mission.completed`
- `mission.reply_rejected`
- `mission.reopened`
- `mission.cancelled`
这些事件全部支持按任务分类路由。
- `mission.unreplied`
- `mission.unreplied` 并非由业务 signal 直接触发,而是由后台每分钟一次的扫描任务按任务对象上的提醒配置触发
## 7. 日志策略
当前日志覆盖以下节点:
- 事件入队
- 没有命中任何可用路由
- 路由命中成功
- backend 发送成功
@@ -146,26 +101,26 @@
当前后台提供两个对象:
- `Notifier`
- `NotifierRoute`
并且:
- `Notifier` 页面支持 inline 维护其下路由
- `NotifierRoute` 也支持单独管理
`Notifier` inline 场景下route 的 `merchant` 会自动同步为当前 notifier 的商户,避免管理人员重复录入。
## 11. Mission 上的未回复提醒状态字段
## 9. 迁移策略
当前未回复提醒的核心状态全部放在 `Mission` 对象自身:
本次从旧结构迁到新结构时,做了自动回填:
- `notify_if_unreplied`
- `unreplied_notify_interval_minutes`
- `unreplied_notify_max_count`
- `unreplied_notify_sent_count`
- `unreplied_last_notified_at`
- 对每条旧 `Notifier(event_key=...)`
- 自动创建一条 `NotifierRoute`
- `event_key` 原样继承
- `mission_category = null`
- `description` 标记为自动迁移生成
这样做的原因是:
- 配置和运行态统一放在任务对象上,最容易排查
- 不需要额外的提醒计划表或提醒历史表就能支撑当前需求
- 最大提醒次数和上次提醒时间都能直接在任务详情中观察到
这样旧配置不会因为结构调整而丢失。
## 10. 测试覆盖重点

View File

@@ -77,6 +77,7 @@
- `sales_items` 现在返回的是带图片字段的销售品详情结构
- 若销售品关联的 `PrintingJob.product` 存在主图,则会返回 `product_image_url`
- 若无关联图片,则 `product_image_url``null`
- 若出货单已经被驳回,则其销售品会在驳回时被自动解绑,因此此处的 `sales_items` 会变为空数组
### 出货单详情
@@ -237,15 +238,21 @@
| created_at | string | 创建时间 |
| updated_at | string | 更新时间 |
说明:
- 后端内部会在驳回时记录 `rejected_sales_item_ids` 审计快照,但该字段当前不对前端返回
### 状态流转规则
- `草稿(未发布)` 只能流转到 `已发布`
- `已发布` 可以流转到 `已审核` / `已驳回` / `已取消`
- `已驳回` 可以流转到 `已审核` / `已取消`
- `已驳回` 目前只能流转到 `已取消`
- `已审核` 可以流转到 `已取消`
- `已取消` 不可再流转到其他状态
- 重复设置同一状态保持幂等,不报错
- 进入 `已审核` 时必须提供审核人
- 进入 `已驳回` 时,系统会自动解绑当前出货单下的销售品,并将它们退回待分配池
- 驳回时解绑前的销售品 ID 会保存到后端审计字段 `rejected_sales_item_ids`
### 错误响应
@@ -426,6 +433,10 @@
3. 只返回至少拥有 1 条未出货销售品的客户
4. 返回客户基础信息及未出货销售品数量
补充说明:
- 若某销售品原先绑定在一个出货单上,而该出货单后来被驳回,这些销售品会被自动解绑,因此会重新计入这里的“未出货销售品”统计
### 响应格式
```json
@@ -493,6 +504,10 @@
4. 可通过 `include_already_has_shipment=true` 包含已出货销售品
5. 可通过 `external_order_id` 按关联生产订单的外部订单号精确筛选
补充说明:
- 被驳回出货单解绑的销售品会重新满足 `shipment = null` 条件,因此默认查询会再次返回它们
### 响应格式
```json
@@ -660,6 +675,10 @@ curl -X GET \
- `false`(默认):只返回 `shipment` 为空的销售品(待出货)
- `true`:返回所有销售品(包含已出货的)
补充说明:
- 若某销售品所在出货单已被驳回,该销售品会在驳回时自动解绑,因此默认查询会再次将其视为待出货销售品
### 响应格式
```json

View File

@@ -0,0 +1,58 @@
# Shipment 驳回逻辑更新说明(给前端)
本文档用于同步 2026-04-17 起 `Shipment` 驳回逻辑的最新行为。
## 结论
- 出货单状态从 `已发布` 改为 `已驳回` 时,系统会自动解绑该出货单当前绑定的所有销售品
- 被解绑的销售品会重新回到“待分配/待出货”池
- 因此这些销售品会重新出现在默认的销售品查询接口中
- 后端会保留一份驳回前销售品 ID 快照用于审计,但这个字段当前不对前端返回
## 对前端的影响
### 1. 出货单详情页
- 如果一个出货单被驳回,再次查询该出货单详情时,`sales_items` 通常会变成空数组
- `items_count` 也会随之变为 `0`
- 这不是数据丢失,而是因为销售品已经被退回待分配池
### 2. 销售品选择页 / 待分配列表
- 原先属于该出货单的销售品,在驳回后会重新出现在默认查询结果里
- 包括这些接口的默认结果:
- `GET /api/v1/shipment/sales-items/customers/`
- `GET /api/v1/shipment/sales-items/by-customer/{customer_id}/`
- `GET /api/v1/shipment/sales-items/by-printing-order/{printing_order_id}/`
### 3. 状态流转按钮
- `已驳回 -> 已审核` 这条路径已临时关闭
- 前端如果有“驳回后再次审核”的按钮或操作入口,需要先隐藏或禁用
- 当前 `已驳回` 状态只允许继续走 `已取消`
## 当前不变的地方
- 驳回接口本身没有新增请求参数
- 驳回接口当前也没有新增响应字段
- 审计字段 `rejected_sales_item_ids` 仅在后端内部使用,前端暂时拿不到
## 建议前端处理方式
- 当用户把出货单驳回成功后,前端应刷新:
- 当前出货单详情
- 销售品待分配列表
- 客户待出货统计
- 如果页面存在“已驳回后继续审核”的操作,需要立即下线或禁用
## 可直接复制的简版说明
```md
出货单驳回逻辑已更新:
1. 出货单从“已发布”改成“已驳回”后,系统会自动解绑该出货单当前绑定的所有销售品。
2. 被解绑的销售品会重新回到待分配池,所以会重新出现在默认的销售品查询结果里。
3. 驳回后的出货单详情中,`sales_items` 通常会变成空数组,`items_count` 也会变成 0这是预期行为。
4. 后端会保留一份驳回前绑定销售品 ID 的审计快照,但这个字段当前不对前端返回。
5. `已驳回 -> 已审核` 已临时关闭,前端如有对应按钮请隐藏或禁用。
```

View File

@@ -13,6 +13,9 @@
- 该接口只负责修改 `Shipment.status`
- 业务字段修改仍然使用 `/api/v1/shipment/shipments/{id}/`
- `PATCH``PUT` 当前行为一致,都是按请求体中的 `status` 执行状态流转
- 当前驳回逻辑有额外副作用:会解绑该出货单当前绑定的销售品,使其重新回到待分配池
- 驳回时后端会把解绑前的销售品 ID 保存到内部审计字段 `rejected_sales_item_ids`
- `rejected_sales_item_ids` 当前仅用于后端审计,暂不在 API 响应中返回
## 请求体
@@ -35,7 +38,7 @@
- `草稿(未发布)` 只能流转到 `已发布`
- `已发布` 可以流转到 `已审核` / `已驳回` / `已取消`
- `已驳回` 可以流转到 `已审核` / `已取消`
- `已驳回` 目前只能流转到 `已取消`
- `已审核` 可以流转到 `已取消`
- `已取消` 不可再流转
- 重复设置同一状态时保持幂等
@@ -44,8 +47,14 @@
- 进入 `已审核` 时,接口会自动将当前 `request.user` 写入 `approved_by`
- 进入 `已取消` 时,接口会自动将当前 `request.user` 写入 `cancelled_by`
- 进入 `已驳回` 时,接口会先记录当前绑定销售品 ID 的审计快照,再解除这些销售品与当前出货单的绑定
- 驳回完成后,这些销售品会重新出现在默认的“待出货/待分配”查询结果中
- 每次成功状态流转都会更新 `status_modified_at`
说明:
- 由于驳回现在会解绑销售品,`已驳回 -> 已审核` 已暂时关闭,避免出现“空出货单被审核”的状态语义冲突
## 请求示例
### 1. 草稿发布
@@ -70,6 +79,17 @@ Content-Type: application/json
}
```
### 3. 已发布驳回
```http
PATCH /api/v1/shipment/shipments/12/status/
Content-Type: application/json
{
"status": 4
}
```
## 成功响应示例
```json
@@ -113,7 +133,15 @@ Content-Type: application/json
}
```
### 2. 越权或对象不存在
### 2. 已驳回后再次审核
```json
{
"detail": "不允许将出货单状态从 已驳回 修改为 已审核"
}
```
### 3. 越权或对象不存在
```json
{
@@ -121,7 +149,7 @@ Content-Type: application/json
}
```
### 3. 请求体不合法
### 4. 请求体不合法
```json
{