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

@@ -1968,7 +1968,49 @@ class ShipmentStatusServiceTestCase(TestCase):
self.assertEqual(self.shipment.approved_by, self.user) self.assertEqual(self.shipment.approved_by, self.user)
self.assertIsNotNone(self.shipment.status_modified_at) self.assertIsNotNone(self.shipment.status_modified_at)
def test_modify_status_allows_rejected_to_approved(self): def test_modify_status_rejected_unbinds_sales_items_and_snapshots_ids(self):
from shipment.services import modify_status
sales_item_1 = shipment_models.SalesItem.objects.create(
merchant=self.merchant,
shipment=self.shipment,
name="驳回销售品1",
quantity=Decimal("10.00"),
unit=shipment_models.UnitChoices.METER,
created_by=self.user,
)
sales_item_2 = shipment_models.SalesItem.objects.create(
merchant=self.merchant,
shipment=self.shipment,
name="驳回销售品2",
quantity=Decimal("20.00"),
unit=shipment_models.UnitChoices.METER,
created_by=self.user,
)
modify_status(
self.shipment,
target_status=shipment_models.ShipmentStatus.PUBLISHED,
operator=self.user,
)
modify_status(
self.shipment,
target_status=shipment_models.ShipmentStatus.REJECTED,
operator=self.user,
)
self.shipment.refresh_from_db()
sales_item_1.refresh_from_db()
sales_item_2.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.REJECTED)
self.assertEqual(
self.shipment.rejected_sales_item_ids,
[sales_item_1.id, sales_item_2.id],
)
self.assertIsNone(sales_item_1.shipment_id)
self.assertIsNone(sales_item_2.shipment_id)
def test_modify_status_rejects_rejected_to_approved(self):
from shipment.services import modify_status from shipment.services import modify_status
modify_status( modify_status(
@@ -1981,16 +2023,14 @@ class ShipmentStatusServiceTestCase(TestCase):
target_status=shipment_models.ShipmentStatus.REJECTED, target_status=shipment_models.ShipmentStatus.REJECTED,
operator=self.user, operator=self.user,
) )
modify_status(
self.shipment,
target_status=shipment_models.ShipmentStatus.APPROVED,
operator=self.user,
approved_by=self.user,
)
self.shipment.refresh_from_db() with self.assertRaisesMessage(ValueError, "不允许将出货单状态从 已驳回 修改为 已审核"):
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.APPROVED) modify_status(
self.assertEqual(self.shipment.approved_by, self.user) self.shipment,
target_status=shipment_models.ShipmentStatus.APPROVED,
operator=self.user,
approved_by=self.user,
)
def test_modify_status_rejects_rejected_to_published(self): def test_modify_status_rejects_rejected_to_published(self):
from shipment.services import modify_status from shipment.services import modify_status
@@ -2146,6 +2186,15 @@ class ShipmentStatusAPITestCase(APITestCase):
def test_patch_status_published_to_rejected_success(self): def test_patch_status_published_to_rejected_success(self):
from shipment.services import modify_status from shipment.services import modify_status
sales_item = shipment_models.SalesItem.objects.create(
merchant=self.merchant,
shipment=self.shipment,
name="状态API驳回销售品",
quantity=Decimal("15.00"),
unit=shipment_models.UnitChoices.METER,
created_by=self.user,
)
modify_status( modify_status(
self.shipment, self.shipment,
target_status=shipment_models.ShipmentStatus.PUBLISHED, target_status=shipment_models.ShipmentStatus.PUBLISHED,
@@ -2160,7 +2209,10 @@ class ShipmentStatusAPITestCase(APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.shipment.refresh_from_db() self.shipment.refresh_from_db()
sales_item.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.REJECTED) self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.REJECTED)
self.assertEqual(self.shipment.rejected_sales_item_ids, [sales_item.id])
self.assertIsNone(sales_item.shipment_id)
def test_patch_status_to_cancelled_sets_cancelled_by(self): def test_patch_status_to_cancelled_sets_cancelled_by(self):
from shipment.services import modify_status from shipment.services import modify_status

View File

@@ -69,10 +69,42 @@ class MissionV2APITest(TestCase):
self.assertFalse(mission.is_urgent) self.assertFalse(mission.is_urgent)
self.assertFalse(mission.is_completed) self.assertFalse(mission.is_completed)
self.assertFalse(mission.is_cancelled) self.assertFalse(mission.is_cancelled)
self.assertFalse(mission.notify_if_unreplied)
self.assertEqual(mission.unreplied_notify_max_count, 5)
self.assertEqual(resp.data["category"], self.default_category.id) self.assertEqual(resp.data["category"], self.default_category.id)
self.assertEqual(resp.data["category_name"], "通用") self.assertEqual(resp.data["category_name"], "通用")
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id]) self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id])
def test_create_mission_supports_unreplied_notification_fields(self):
resp = self.client.post(
"/api/v2/missions/",
{
"description": "需要未回复提醒",
"notify_if_unreplied": True,
"unreplied_notify_interval_minutes": 30,
},
format="json",
)
self.assertEqual(resp.status_code, 201)
self.assertTrue(resp.data["notify_if_unreplied"])
self.assertEqual(resp.data["unreplied_notify_interval_minutes"], 30)
self.assertEqual(resp.data["unreplied_notify_max_count"], 5)
self.assertEqual(resp.data["unreplied_notify_sent_count"], 0)
def test_create_mission_rejects_missing_interval_when_unreplied_notification_enabled(self):
resp = self.client.post(
"/api/v2/missions/",
{
"description": "缺少提醒间隔",
"notify_if_unreplied": True,
},
format="json",
)
self.assertEqual(resp.status_code, 400)
self.assertIn("unreplied_notify_interval_minutes", resp.data)
def test_create_mission_rejects_status_fields(self): def test_create_mission_rejects_status_fields(self):
resp = self.client.post( resp = self.client.post(
"/api/v2/missions/", "/api/v2/missions/",
@@ -146,6 +178,25 @@ class MissionV2APITest(TestCase):
self.assertEqual(resp.data["category_name"], "跟进") self.assertEqual(resp.data["category_name"], "跟进")
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id]) self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id])
def test_patch_mission_updates_unreplied_notification_fields(self):
mission = self._create_mission()
resp = self.client.patch(
f"/api/v2/missions/{mission.id}/",
{
"notify_if_unreplied": True,
"unreplied_notify_interval_minutes": 12,
"unreplied_notify_max_count": 7,
},
format="json",
)
self.assertEqual(resp.status_code, 200)
mission.refresh_from_db()
self.assertTrue(mission.notify_if_unreplied)
self.assertEqual(mission.unreplied_notify_interval_minutes, 12)
self.assertEqual(mission.unreplied_notify_max_count, 7)
def test_list_only_returns_current_merchant_missions(self): def test_list_only_returns_current_merchant_missions(self):
visible = self._create_mission(description="可见任务") visible = self._create_mission(description="可见任务")
mission_models.Mission.objects.create( mission_models.Mission.objects.create(

View File

@@ -36,6 +36,9 @@ class MissionWriteSerializer(serializers.Serializer):
category = serializers.IntegerField(required=False, min_value=1) category = serializers.IntegerField(required=False, min_value=1)
content_type = serializers.IntegerField(required=False, allow_null=True, min_value=1) content_type = serializers.IntegerField(required=False, allow_null=True, min_value=1)
content_id = serializers.IntegerField(required=False, allow_null=True, min_value=1) content_id = serializers.IntegerField(required=False, allow_null=True, min_value=1)
notify_if_unreplied = serializers.BooleanField(required=False)
unreplied_notify_interval_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=1)
unreplied_notify_max_count = serializers.IntegerField(required=False, min_value=1)
participant_ids = serializers.ListField( participant_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1), child=serializers.IntegerField(min_value=1),
required=False, required=False,
@@ -73,6 +76,20 @@ class MissionWriteSerializer(serializers.Serializer):
attrs["content_type"] = ContentType.objects.get(id=content_type_id) attrs["content_type"] = ContentType.objects.get(id=content_type_id)
except ContentType.DoesNotExist as exc: except ContentType.DoesNotExist as exc:
raise serializers.ValidationError({"content_type": "ContentType 不存在"}) from exc raise serializers.ValidationError({"content_type": "ContentType 不存在"}) from exc
instance = self.context.get("instance")
final_notify_if_unreplied = attrs.get(
"notify_if_unreplied",
getattr(instance, "notify_if_unreplied", False),
)
final_interval = attrs.get(
"unreplied_notify_interval_minutes",
getattr(instance, "unreplied_notify_interval_minutes", None),
)
if final_notify_if_unreplied and final_interval is None:
raise serializers.ValidationError(
{"unreplied_notify_interval_minutes": "开启未回复提醒时必须设置提醒间隔"}
)
return attrs return attrs
@@ -126,6 +143,11 @@ class MissionSerializer(serializers.ModelSerializer):
"is_urgent", "is_urgent",
"is_completed", "is_completed",
"is_cancelled", "is_cancelled",
"notify_if_unreplied",
"unreplied_notify_interval_minutes",
"unreplied_notify_max_count",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"cancelled_at", "cancelled_at",
"creator", "creator",
"cancelled_by", "cancelled_by",
@@ -313,6 +335,9 @@ class MissionListCreateView(APIView):
content_type=data.get("content_type"), content_type=data.get("content_type"),
content_id=data.get("content_id"), content_id=data.get("content_id"),
participant_ids=data.get("participant_ids"), participant_ids=data.get("participant_ids"),
notify_if_unreplied=data.get("notify_if_unreplied", False),
unreplied_notify_interval_minutes=data.get("unreplied_notify_interval_minutes"),
unreplied_notify_max_count=data.get("unreplied_notify_max_count", 5),
) )
except ValueError as exc: except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
@@ -333,7 +358,11 @@ class MissionDetailView(APIView):
def patch(self, request, mission_id): def patch(self, request, mission_id):
employee = _get_employee(request) employee = _get_employee(request)
mission = self.get_object(request, mission_id) mission = self.get_object(request, mission_id)
serializer = MissionWriteSerializer(data=request.data, partial=True, context={"employee": employee}) serializer = MissionWriteSerializer(
data=request.data,
partial=True,
context={"employee": employee, "instance": mission},
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
try: try:
@@ -346,6 +375,21 @@ class MissionDetailView(APIView):
content_id=data.get("content_id"), content_id=data.get("content_id"),
update_content_object=("content_type" in request.data or "content_id" in request.data), update_content_object=("content_type" in request.data or "content_id" in request.data),
participant_ids=data.get("participant_ids"), participant_ids=data.get("participant_ids"),
notify_if_unreplied=(
data["notify_if_unreplied"]
if "notify_if_unreplied" in data
else mission_services.UNSET
),
unreplied_notify_interval_minutes=(
data["unreplied_notify_interval_minutes"]
if "unreplied_notify_interval_minutes" in data
else mission_services.UNSET
),
unreplied_notify_max_count=(
data["unreplied_notify_max_count"]
if "unreplied_notify_max_count" in data
else mission_services.UNSET
),
) )
except ValueError as exc: except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)

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_by` | 撤销人,由后端写入 |
| `rejected_at` | 撤销时间,由后端写入 | | `rejected_at` | 撤销时间,由后端写入 |
说明:
- 本次新增的“未回复提醒配置字段”不属于状态字段,允许通过普通创建/更新接口维护
## content_type 说明 ## content_type 说明
`content_type``content_id` 是可选的"关联业务对象"字段,用于把任务挂靠到系统中的某个具体业务单据或对象上。 `content_type``content_id` 是可选的"关联业务对象"字段,用于把任务挂靠到系统中的某个具体业务单据或对象上。
@@ -109,6 +113,11 @@
"is_urgent": false, "is_urgent": false,
"is_completed": false, "is_completed": false,
"is_cancelled": 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, "cancelled_at": null,
"creator": { "creator": {
"id": 20, "id": 20,
@@ -246,6 +255,9 @@
| `category` | int | 否 | 任务分类 ID不传时默认使用当前商户下名称为“通用”的分类不存在则自动创建 | | `category` | int | 否 | 任务分类 ID不传时默认使用当前商户下名称为“通用”的分类不存在则自动创建 |
| `content_type` | int/null | 否 | Django ContentType ID必须与 `content_id` 同时提供或同时省略 | | `content_type` | int/null | 否 | Django ContentType ID必须与 `content_id` 同时提供或同时省略 |
| `content_id` | int/null | 否 | 关联业务对象 ID必须与 `content_type` 同时提供或同时省略 | | `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 列表,必须属于当前商户 | | `participant_ids` | int[] | 否 | 参与者员工 ID 列表,必须属于当前商户 |
说明: 说明:
@@ -254,6 +266,8 @@
- `category_name` 为只读字段,由后端根据分类表返回 - `category_name` 为只读字段,由后端根据分类表返回
- `is_urgent``is_completed``is_cancelled` 均按默认值创建,不接受请求参数 - `is_urgent``is_completed``is_cancelled` 均按默认值创建,不接受请求参数
- 若关联对象存在 `merchant_id` 字段,后端会校验它必须属于当前商户 - 若关联对象存在 `merchant_id` 字段,后端会校验它必须属于当前商户
-`notify_if_unreplied=true`,则必须同时传入 `unreplied_notify_interval_minutes`
- `unreplied_notify_sent_count``unreplied_last_notified_at` 为只读运行时字段,由后端维护
请求示例: 请求示例:
@@ -261,6 +275,9 @@
{ {
"description": "跟进客户问题", "description": "跟进客户问题",
"category": 1, "category": 1,
"notify_if_unreplied": true,
"unreplied_notify_interval_minutes": 30,
"unreplied_notify_max_count": 5,
"participant_ids": [21, 22] "participant_ids": [21, 22]
} }
``` ```
@@ -289,10 +306,18 @@
| `category` | int | 任务分类 ID | | `category` | int | 任务分类 ID |
| `content_type` | int/null | 关联对象类型;必须与 `content_id` 同时提供 | | `content_type` | int/null | 关联对象类型;必须与 `content_id` 同时提供 |
| `content_id` | int/null | 关联对象 ID必须与 `content_type` 同时提供 | | `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[] | 重置参与者列表 | | `participant_ids` | int[] | 重置参与者列表 |
禁止更新状态字段,见“基本约定” 禁止更新状态字段,见“基本约定”
补充说明:
- 更新 `notify_if_unreplied``unreplied_notify_interval_minutes` 时,后端会重置当前任务的未回复提醒计数与上次提醒时间
- 更新 `unreplied_notify_max_count` 不会重置已提醒次数
成功响应:`Mission` 成功响应:`Mission`
## 删除任务 ## 删除任务
@@ -390,6 +415,7 @@ HTTP 状态码:`405 Method Not Allowed`
- `Mission.cancelled_by=当前员工` - `Mission.cancelled_by=当前员工`
- `Mission.cancelled_at=当前时间` - `Mission.cancelled_at=当前时间`
- 不强行修改 `Mission.is_completed` - 不强行修改 `Mission.is_completed`
- 后端会停止当前任务后续的未回复提醒,并保留任务上的提醒历史字段供查看
成功响应:`Mission` 成功响应:`Mission`
@@ -431,7 +457,37 @@ HTTP 状态码:`405 Method Not Allowed`
- 当前回应标记为 `is_rejected=true` - 当前回应标记为 `is_rejected=true`
- 当前回应的 `rejected_by``rejected_at` 由后端写入 - 当前回应的 `rejected_by``rejected_at` 由后端写入
- 如果当前回应原本是唯一有效的结束回应,则对应任务会被重新置为未完成 - 如果当前回应原本是唯一有效的结束回应,则对应任务会被重新置为未完成
- 如果任务重新回到“没有任何有效回复”的状态,后端会重置未回复提醒计数,并从新的空窗期重新开始计算后续提醒
成功响应:`MissionReply` 成功响应:`MissionReply`
无权限响应:`403 Forbidden` 无权限响应:`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.created`
- `mission.replied` - `mission.replied`
- `mission.completed` - `mission.completed`
- `mission.unreplied`
- `mission.reply_rejected` - `mission.reply_rejected`
- `mission.reopened` - `mission.reopened`
- `mission.cancelled` - `mission.cancelled`
@@ -144,6 +145,7 @@
- `mission.created` - `mission.created`
- `mission.completed` - `mission.completed`
- `mission.unreplied`
### 6.4 mission_category ### 6.4 mission_category
@@ -254,6 +256,7 @@
- `mission_created` - `mission_created`
- `mission_replied` - `mission_replied`
- `mission_completed` - `mission_completed`
- `mission_unreplied`
- `mission_reply_rejected` - `mission_reply_rejected`
- `mission_reopened` - `mission_reopened`
- `mission_cancelled` - `mission_cancelled`
@@ -261,7 +264,52 @@
管理人员通常只需要填 `template_key`,不需要改代码。 管理人员通常只需要填 `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` 2. 取消勾选 `is_enabled`
3. 保存 3. 保存
## 11. 常见问题 ## 12. 常见问题
### 11.1 为什么事件发生了,但没有收到通知 ### 11.1 为什么事件发生了,但没有收到通知
@@ -322,7 +370,7 @@
不会。 不会。
系统会自动去重,并优先使用更具体的分类路由。 系统会自动去重,并优先使用更具体的分类路由。
## 12. 管理建议 ## 13. 管理建议
- 先建 `Notifier`,再建 `NotifierRoute` - 先建 `Notifier`,再建 `NotifierRoute`
- 通知器名称中写清楚目标群 - 通知器名称中写清楚目标群
@@ -330,7 +378,7 @@
- 先停用再删除 - 先停用再删除
- 先配置一条路由做验证,再批量扩展 - 先配置一条路由做验证,再批量扩展
## 13. 最简操作结论 ## 14. 最简操作结论
如果你只想快速配置一条通知,记住这 6 个关键点就够了: 如果你只想快速配置一条通知,记住这 6 个关键点就够了:

View File

@@ -41,49 +41,27 @@
### 3.1 Notifier ### 3.1 Notifier
`Notifier` 负责“怎么发”:
- `merchant`
- `name`
- `channel`
- `template_key`
- `is_enabled`
- `config` - `config`
- `description` - `description`
当前 `Notifier` 已不再直接持有 `event_key` 当前 `Notifier` 已不再直接持有 `event_key`
### 3.2 NotifierRoute
`NotifierRoute` 负责“何时发、发给谁”: `NotifierRoute` 负责“何时发、发给谁”:
- `merchant` - `merchant`
- `notifier` - `notifier`
- `event_key`
- `mission_category`
- `is_enabled`
- `description`
其中: 其中:
- `mission_category = null` 表示该事件的通配路由 - `mission_category = null` 表示该事件的通配路由
- `mission_category != null` 表示任务分类专用路由
### 3.3 约束设计
当前约束:
- `Notifier` 在同商户下 `name` 唯一 - `Notifier` 在同商户下 `name` 唯一
- `NotifierRoute` 在同一 `notifier + event_key + mission_category` 下唯一 - `NotifierRoute` 在同一 `notifier + event_key + mission_category` 下唯一
- `NotifierRoute` 额外限制同一 `notifier + event_key` 只能有一条通配路由 - `NotifierRoute` 额外限制同一 `notifier + event_key` 只能有一条通配路由
## 4. 路由匹配规则
当前 `dispatch_notification_event(...)` 的匹配规则为: 当前 `dispatch_notification_event(...)` 的匹配规则为:
1. 先按 `merchant + event_key + route.is_enabled=True + notifier.is_enabled=True` 查路由 1. 先按 `merchant + event_key + route.is_enabled=True + notifier.is_enabled=True` 查路由
2. 如果 payload 中带有 `category_id`
- 匹配该分类的专用路由
- 也允许匹配通配路由 - 也允许匹配通配路由
3. 如果 payload 中没有 `category_id` 3. 如果 payload 中没有 `category_id`
- 只匹配通配路由 - 只匹配通配路由
@@ -91,11 +69,6 @@
- 只保留一条 - 只保留一条
- 优先保留专用路由 - 优先保留专用路由
这样可以同时满足:
- 分类专用通知
- 默认兜底通知
- 多群并发通知
- 同一通知器不重复发送 - 同一通知器不重复发送
## 5. 当前调用链 ## 5. 当前调用链
@@ -103,32 +76,14 @@
通知链路如下: 通知链路如下:
1. `mission.services` 在事务提交后发 signal 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. 已接入的事件 ## 6. 已接入的事件
当前 `mission` 已接入: 当前 `mission` 已接入:
- `mission.created` - `mission.unreplied`
- `mission.replied` - `mission.unreplied` 并非由业务 signal 直接触发,而是由后台每分钟一次的扫描任务按任务对象上的提醒配置触发
- `mission.completed`
- `mission.reply_rejected`
- `mission.reopened`
- `mission.cancelled`
这些事件全部支持按任务分类路由。
## 7. 日志策略 ## 7. 日志策略
当前日志覆盖以下节点:
- 事件入队
- 没有命中任何可用路由 - 没有命中任何可用路由
- 路由命中成功 - 路由命中成功
- backend 发送成功 - backend 发送成功
@@ -146,26 +101,26 @@
当前后台提供两个对象: 当前后台提供两个对象:
- `Notifier`
- `NotifierRoute`
并且: 并且:
- `Notifier` 页面支持 inline 维护其下路由
- `NotifierRoute` 也支持单独管理
`Notifier` inline 场景下route 的 `merchant` 会自动同步为当前 notifier 的商户,避免管理人员重复录入。 `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. 测试覆盖重点 ## 10. 测试覆盖重点

View File

@@ -77,6 +77,7 @@
- `sales_items` 现在返回的是带图片字段的销售品详情结构 - `sales_items` 现在返回的是带图片字段的销售品详情结构
- 若销售品关联的 `PrintingJob.product` 存在主图,则会返回 `product_image_url` - 若销售品关联的 `PrintingJob.product` 存在主图,则会返回 `product_image_url`
- 若无关联图片,则 `product_image_url``null` - 若无关联图片,则 `product_image_url``null`
- 若出货单已经被驳回,则其销售品会在驳回时被自动解绑,因此此处的 `sales_items` 会变为空数组
### 出货单详情 ### 出货单详情
@@ -237,15 +238,21 @@
| created_at | string | 创建时间 | | created_at | string | 创建时间 |
| updated_at | string | 更新时间 | | updated_at | string | 更新时间 |
说明:
- 后端内部会在驳回时记录 `rejected_sales_item_ids` 审计快照,但该字段当前不对前端返回
### 状态流转规则 ### 状态流转规则
- `草稿(未发布)` 只能流转到 `已发布` - `草稿(未发布)` 只能流转到 `已发布`
- `已发布` 可以流转到 `已审核` / `已驳回` / `已取消` - `已发布` 可以流转到 `已审核` / `已驳回` / `已取消`
- `已驳回` 可以流转到 `已审核` / `已取消` - `已驳回` 目前只能流转到 `已取消`
- `已审核` 可以流转到 `已取消` - `已审核` 可以流转到 `已取消`
- `已取消` 不可再流转到其他状态 - `已取消` 不可再流转到其他状态
- 重复设置同一状态保持幂等,不报错 - 重复设置同一状态保持幂等,不报错
- 进入 `已审核` 时必须提供审核人 - 进入 `已审核` 时必须提供审核人
- 进入 `已驳回` 时,系统会自动解绑当前出货单下的销售品,并将它们退回待分配池
- 驳回时解绑前的销售品 ID 会保存到后端审计字段 `rejected_sales_item_ids`
### 错误响应 ### 错误响应
@@ -426,6 +433,10 @@
3. 只返回至少拥有 1 条未出货销售品的客户 3. 只返回至少拥有 1 条未出货销售品的客户
4. 返回客户基础信息及未出货销售品数量 4. 返回客户基础信息及未出货销售品数量
补充说明:
- 若某销售品原先绑定在一个出货单上,而该出货单后来被驳回,这些销售品会被自动解绑,因此会重新计入这里的“未出货销售品”统计
### 响应格式 ### 响应格式
```json ```json
@@ -493,6 +504,10 @@
4. 可通过 `include_already_has_shipment=true` 包含已出货销售品 4. 可通过 `include_already_has_shipment=true` 包含已出货销售品
5. 可通过 `external_order_id` 按关联生产订单的外部订单号精确筛选 5. 可通过 `external_order_id` 按关联生产订单的外部订单号精确筛选
补充说明:
- 被驳回出货单解绑的销售品会重新满足 `shipment = null` 条件,因此默认查询会再次返回它们
### 响应格式 ### 响应格式
```json ```json
@@ -660,6 +675,10 @@ curl -X GET \
- `false`(默认):只返回 `shipment` 为空的销售品(待出货) - `false`(默认):只返回 `shipment` 为空的销售品(待出货)
- `true`:返回所有销售品(包含已出货的) - `true`:返回所有销售品(包含已出货的)
补充说明:
- 若某销售品所在出货单已被驳回,该销售品会在驳回时自动解绑,因此默认查询会再次将其视为待出货销售品
### 响应格式 ### 响应格式
```json ```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` - 该接口只负责修改 `Shipment.status`
- 业务字段修改仍然使用 `/api/v1/shipment/shipments/{id}/` - 业务字段修改仍然使用 `/api/v1/shipment/shipments/{id}/`
- `PATCH``PUT` 当前行为一致,都是按请求体中的 `status` 执行状态流转 - `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` 写入 `approved_by`
- 进入 `已取消` 时,接口会自动将当前 `request.user` 写入 `cancelled_by` - 进入 `已取消` 时,接口会自动将当前 `request.user` 写入 `cancelled_by`
- 进入 `已驳回` 时,接口会先记录当前绑定销售品 ID 的审计快照,再解除这些销售品与当前出货单的绑定
- 驳回完成后,这些销售品会重新出现在默认的“待出货/待分配”查询结果中
- 每次成功状态流转都会更新 `status_modified_at` - 每次成功状态流转都会更新 `status_modified_at`
说明:
- 由于驳回现在会解绑销售品,`已驳回 -> 已审核` 已暂时关闭,避免出现“空出货单被审核”的状态语义冲突
## 请求示例 ## 请求示例
### 1. 草稿发布 ### 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 ```json
@@ -113,7 +133,15 @@ Content-Type: application/json
} }
``` ```
### 2. 越权或对象不存在 ### 2. 已驳回后再次审核
```json
{
"detail": "不允许将出货单状态从 已驳回 修改为 已审核"
}
```
### 3. 越权或对象不存在
```json ```json
{ {
@@ -121,7 +149,7 @@ Content-Type: application/json
} }
``` ```
### 3. 请求体不合法 ### 4. 请求体不合法
```json ```json
{ {

View File

@@ -606,6 +606,13 @@ CELERY_BEAT_SCHEDULE = {
'limit': 100, 'limit': 100,
}, },
}, },
'notify_unreplied_missions_every_minute': {
'task': 'notifier.tasks.notify_unreplied_missions_task',
'schedule': crontab(minute='*'),
'kwargs': {
'limit': 100,
},
},
'backfill_external_product_images_hourly': { 'backfill_external_product_images_hourly': {
'task': 'api_v1.tasks.backfill_external_product_images', 'task': 'api_v1.tasks.backfill_external_product_images',
'schedule': crontab(minute=17), 'schedule': crontab(minute=17),

View File

@@ -0,0 +1,36 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("mission", "0004_missioncategory_refactor"),
]
operations = [
migrations.AddField(
model_name="mission",
name="notify_if_unreplied",
field=models.BooleanField(db_index=True, default=False, verbose_name="是否开启未回复提醒"),
),
migrations.AddField(
model_name="mission",
name="unreplied_last_notified_at",
field=models.DateTimeField(blank=True, null=True, verbose_name="上一次未回复提醒时间"),
),
migrations.AddField(
model_name="mission",
name="unreplied_notify_interval_minutes",
field=models.PositiveIntegerField(blank=True, null=True, verbose_name="未回复提醒间隔(分钟)"),
),
migrations.AddField(
model_name="mission",
name="unreplied_notify_max_count",
field=models.PositiveIntegerField(default=5, verbose_name="未回复最大提醒次数"),
),
migrations.AddField(
model_name="mission",
name="unreplied_notify_sent_count",
field=models.PositiveIntegerField(default=0, verbose_name="未回复已提醒次数"),
),
]

View File

@@ -50,6 +50,19 @@ class Mission(ModelBase):
is_urgent = models.BooleanField(default=False, db_index=True, verbose_name="是否紧急") is_urgent = models.BooleanField(default=False, db_index=True, verbose_name="是否紧急")
is_completed = models.BooleanField(default=False, db_index=True, verbose_name="是否完成") is_completed = models.BooleanField(default=False, db_index=True, verbose_name="是否完成")
is_cancelled = models.BooleanField(default=False, db_index=True, verbose_name="是否取消") is_cancelled = models.BooleanField(default=False, db_index=True, verbose_name="是否取消")
notify_if_unreplied = models.BooleanField(default=False, db_index=True, verbose_name="是否开启未回复提醒")
unreplied_notify_interval_minutes = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name="未回复提醒间隔(分钟)",
)
unreplied_notify_max_count = models.PositiveIntegerField(default=5, verbose_name="未回复最大提醒次数")
unreplied_notify_sent_count = models.PositiveIntegerField(default=0, verbose_name="未回复已提醒次数")
unreplied_last_notified_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="上一次未回复提醒时间",
)
cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name="取消时间") cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name="取消时间")
creator = models.ForeignKey( creator = models.ForeignKey(
"basic_info.Employee", "basic_info.Employee",
@@ -84,6 +97,10 @@ class Mission(ModelBase):
def can_reply(self) -> bool: def can_reply(self) -> bool:
return not self.is_cancelled and not self.has_ending_reply return not self.is_cancelled and not self.has_ending_reply
@property
def has_effective_reply(self) -> bool:
return self.replies.filter(is_rejected=False).exists()
def get_participants(self): def get_participants(self):
return self.participants.select_related("employee") return self.participants.select_related("employee")

View File

@@ -1,4 +1,5 @@
import logging import logging
from datetime import timedelta
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
@@ -16,6 +17,7 @@ from mission.signals import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_MISSION_CATEGORY_NAME = "通用" DEFAULT_MISSION_CATEGORY_NAME = "通用"
UNSET = object()
def _send_signal_on_commit(signal, *, sender, **payload) -> None: def _send_signal_on_commit(signal, *, sender, **payload) -> None:
@@ -66,6 +68,41 @@ def _validate_content_object_merchant(*, content_type, content_id, merchant) ->
raise ValueError("关联业务对象不属于当前商户") raise ValueError("关联业务对象不属于当前商户")
def _validate_unreplied_notification_config(
*,
notify_if_unreplied: bool,
unreplied_notify_interval_minutes: int | None,
unreplied_notify_max_count: int,
) -> None:
if notify_if_unreplied and unreplied_notify_interval_minutes is None:
raise ValueError("开启未回复提醒时必须设置提醒间隔")
if unreplied_notify_interval_minutes is not None and unreplied_notify_interval_minutes <= 0:
raise ValueError("未回复提醒间隔必须大于 0")
if unreplied_notify_max_count <= 0:
raise ValueError("未回复最大提醒次数必须大于 0")
def _reset_unreplied_notification_state(mission: Mission) -> None:
mission.unreplied_notify_sent_count = 0
mission.unreplied_last_notified_at = None
def _has_effective_reply(*, mission: Mission) -> bool:
return mission.replies.filter(is_rejected=False).exists()
def _clear_unreplied_notification_state_if_active(*, mission: Mission) -> None:
if mission.unreplied_notify_sent_count or mission.unreplied_last_notified_at is not None:
_reset_unreplied_notification_state(mission)
mission.save(
update_fields=[
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"updated_at",
]
)
@transaction.atomic @transaction.atomic
def set_mission_participants(*, mission: Mission, participant_ids: list[int]) -> Mission: def set_mission_participants(*, mission: Mission, participant_ids: list[int]) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk) mission = Mission.objects.select_for_update().get(pk=mission.pk)
@@ -101,12 +138,20 @@ def create_mission(
content_type=None, content_type=None,
content_id: int | None = None, content_id: int | None = None,
participant_ids: list[int] | None = None, participant_ids: list[int] | None = None,
notify_if_unreplied: bool = False,
unreplied_notify_interval_minutes: int | None = None,
unreplied_notify_max_count: int = 5,
) -> Mission: ) -> Mission:
if creator is None: if creator is None:
raise ValueError("任务创建者不能为空") raise ValueError("任务创建者不能为空")
merchant = creator.merchant merchant = creator.merchant
_validate_content_object_merchant(content_type=content_type, content_id=content_id, merchant=merchant) _validate_content_object_merchant(content_type=content_type, content_id=content_id, merchant=merchant)
_assert_category_belongs_to_merchant(category, merchant=merchant) _assert_category_belongs_to_merchant(category, merchant=merchant)
_validate_unreplied_notification_config(
notify_if_unreplied=notify_if_unreplied,
unreplied_notify_interval_minutes=unreplied_notify_interval_minutes,
unreplied_notify_max_count=unreplied_notify_max_count,
)
mission = Mission.objects.create( mission = Mission.objects.create(
merchant=merchant, merchant=merchant,
@@ -115,6 +160,9 @@ def create_mission(
category=category or _get_default_mission_category(merchant=merchant), category=category or _get_default_mission_category(merchant=merchant),
content_type=content_type, content_type=content_type,
content_id=content_id, content_id=content_id,
notify_if_unreplied=notify_if_unreplied,
unreplied_notify_interval_minutes=unreplied_notify_interval_minutes,
unreplied_notify_max_count=unreplied_notify_max_count,
) )
if participant_ids is not None: if participant_ids is not None:
set_mission_participants(mission=mission, participant_ids=participant_ids) set_mission_participants(mission=mission, participant_ids=participant_ids)
@@ -138,6 +186,9 @@ def update_mission(
content_id: int | None = None, content_id: int | None = None,
update_content_object: bool = False, update_content_object: bool = False,
participant_ids: list[int] | None = None, participant_ids: list[int] | None = None,
notify_if_unreplied=UNSET,
unreplied_notify_interval_minutes=UNSET,
unreplied_notify_max_count=UNSET,
) -> Mission: ) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk) mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(updated_by, mission, "更新任务的员工") _assert_employee_belongs_to_mission(updated_by, mission, "更新任务的员工")
@@ -160,8 +211,55 @@ def update_mission(
mission.content_id = content_id mission.content_id = content_id
update_fields.extend(["content_type", "content_id"]) update_fields.extend(["content_type", "content_id"])
notify_changed = notify_if_unreplied is not UNSET
interval_changed = unreplied_notify_interval_minutes is not UNSET
max_count_changed = unreplied_notify_max_count is not UNSET
if notify_changed or interval_changed or max_count_changed:
final_notify_if_unreplied = (
notify_if_unreplied
if notify_changed
else mission.notify_if_unreplied
)
final_interval = (
unreplied_notify_interval_minutes
if interval_changed
else mission.unreplied_notify_interval_minutes
)
final_max_count = (
unreplied_notify_max_count
if max_count_changed
else mission.unreplied_notify_max_count
)
_validate_unreplied_notification_config(
notify_if_unreplied=final_notify_if_unreplied,
unreplied_notify_interval_minutes=final_interval,
unreplied_notify_max_count=final_max_count,
)
if notify_changed:
mission.notify_if_unreplied = final_notify_if_unreplied
update_fields.append("notify_if_unreplied")
if interval_changed:
mission.unreplied_notify_interval_minutes = final_interval
update_fields.append("unreplied_notify_interval_minutes")
if max_count_changed:
mission.unreplied_notify_max_count = final_max_count
update_fields.append("unreplied_notify_max_count")
if not final_notify_if_unreplied:
_reset_unreplied_notification_state(mission)
update_fields.extend([
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
])
elif notify_changed or interval_changed:
_reset_unreplied_notification_state(mission)
update_fields.extend([
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
])
if update_fields: if update_fields:
mission.save(update_fields=[*update_fields, "updated_at"]) mission.save(update_fields=[*dict.fromkeys(update_fields), "updated_at"])
if participant_ids is not None: if participant_ids is not None:
set_mission_participants(mission=mission, participant_ids=participant_ids) set_mission_participants(mission=mission, participant_ids=participant_ids)
return mission return mission
@@ -189,7 +287,15 @@ def create_mission_reply(
) )
if ends_task and not mission.is_completed: if ends_task and not mission.is_completed:
mission.is_completed = True mission.is_completed = True
mission.save(update_fields=["is_completed", "updated_at"]) _reset_unreplied_notification_state(mission)
mission.save(
update_fields=[
"is_completed",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"updated_at",
]
)
_send_signal_on_commit( _send_signal_on_commit(
mission_completed, mission_completed,
sender=Mission, sender=Mission,
@@ -197,6 +303,8 @@ def create_mission_reply(
completed_by=responder, completed_by=responder,
reply=reply, reply=reply,
) )
else:
_clear_unreplied_notification_state_if_active(mission=mission)
_send_signal_on_commit( _send_signal_on_commit(
mission_replied, mission_replied,
sender=MissionReply, sender=MissionReply,
@@ -223,7 +331,18 @@ def reopen_mission(*, mission: Mission, reopened_by) -> Mission:
now = timezone.now() now = timezone.now()
ending_replies.update(ends_task=False, is_rejected=True, rejected_by=reopened_by, rejected_at=now) ending_replies.update(ends_task=False, is_rejected=True, rejected_by=reopened_by, rejected_at=now)
mission.is_completed = False mission.is_completed = False
mission.save(update_fields=["is_completed", "updated_at"]) if mission.notify_if_unreplied and not _has_effective_reply(mission=mission):
_reset_unreplied_notification_state(mission)
mission.save(
update_fields=[
"is_completed",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"updated_at",
]
)
else:
mission.save(update_fields=["is_completed", "updated_at"])
for reply in MissionReply.objects.filter(id__in=rejected_reply_ids): for reply in MissionReply.objects.filter(id__in=rejected_reply_ids):
_send_signal_on_commit( _send_signal_on_commit(
mission_reply_rejected, mission_reply_rejected,
@@ -263,9 +382,23 @@ def reject_reply(*, reply: MissionReply, rejected_by) -> MissionReply:
reply.save(update_fields=["ends_task", "is_rejected", "rejected_by", "rejected_at", "updated_at"]) reply.save(update_fields=["ends_task", "is_rejected", "rejected_by", "rejected_at", "updated_at"])
has_other_ending_reply = mission.replies.exclude(pk=reply.pk).filter(ends_task=True, is_rejected=False).exists() has_other_ending_reply = mission.replies.exclude(pk=reply.pk).filter(ends_task=True, is_rejected=False).exists()
has_effective_reply = mission.replies.exclude(pk=reply.pk).filter(is_rejected=False).exists()
if was_ending_reply and not has_other_ending_reply and mission.is_completed: if was_ending_reply and not has_other_ending_reply and mission.is_completed:
mission.is_completed = False mission.is_completed = False
mission.save(update_fields=["is_completed", "updated_at"]) if mission.notify_if_unreplied and not has_effective_reply:
_reset_unreplied_notification_state(mission)
mission.save(
update_fields=[
"is_completed",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"updated_at",
]
)
else:
mission.save(update_fields=["is_completed", "updated_at"])
elif mission.notify_if_unreplied and not has_effective_reply:
_clear_unreplied_notification_state_if_active(mission=mission)
_send_signal_on_commit( _send_signal_on_commit(
mission_reply_rejected, mission_reply_rejected,
sender=MissionReply, sender=MissionReply,
@@ -288,7 +421,17 @@ def cancel_mission(*, mission: Mission, cancelled_by) -> Mission:
mission.is_cancelled = True mission.is_cancelled = True
mission.cancelled_by = cancelled_by mission.cancelled_by = cancelled_by
mission.cancelled_at = timezone.now() mission.cancelled_at = timezone.now()
mission.save(update_fields=["is_cancelled", "cancelled_by", "cancelled_at", "updated_at"]) _reset_unreplied_notification_state(mission)
mission.save(
update_fields=[
"is_cancelled",
"cancelled_by",
"cancelled_at",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"updated_at",
]
)
_send_signal_on_commit( _send_signal_on_commit(
mission_cancelled, mission_cancelled,
sender=Mission, sender=Mission,
@@ -306,3 +449,10 @@ def set_mission_urgent(*, mission: Mission, updated_by, is_urgent: bool) -> Miss
mission.is_urgent = is_urgent mission.is_urgent = is_urgent
mission.save(update_fields=["is_urgent", "updated_at"]) mission.save(update_fields=["is_urgent", "updated_at"])
return mission return mission
def get_mission_unreplied_due_at(*, mission: Mission):
if mission.unreplied_notify_interval_minutes is None:
return None
base_time = mission.unreplied_last_notified_at or mission.created_at
return base_time + timedelta(minutes=mission.unreplied_notify_interval_minutes)

View File

@@ -1,6 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from unittest.mock import patch from unittest.mock import patch
from django.utils import timezone
from basic_info.models import Employee, Merchant, MerchantTypeEnum from basic_info.models import Employee, Merchant, MerchantTypeEnum
from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply
@@ -160,6 +161,28 @@ class MissionModelTestCase(TestCase):
self.assertFalse(mission.is_urgent) self.assertFalse(mission.is_urgent)
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.responder.id]) self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.responder.id])
def test_create_mission_supports_unreplied_notification_fields(self):
mission = create_mission(
creator=self.creator,
description="带未回复提醒的任务",
notify_if_unreplied=True,
unreplied_notify_interval_minutes=15,
)
self.assertTrue(mission.notify_if_unreplied)
self.assertEqual(mission.unreplied_notify_interval_minutes, 15)
self.assertEqual(mission.unreplied_notify_max_count, 5)
self.assertEqual(mission.unreplied_notify_sent_count, 0)
self.assertIsNone(mission.unreplied_last_notified_at)
def test_create_mission_requires_interval_when_unreplied_notification_enabled(self):
with self.assertRaisesMessage(ValueError, "开启未回复提醒时必须设置提醒间隔"):
create_mission(
creator=self.creator,
description="缺少提醒间隔",
notify_if_unreplied=True,
)
def test_create_mission_rejects_empty_creator(self): def test_create_mission_rejects_empty_creator(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
create_mission(creator=None, description="无创建人") create_mission(creator=None, description="无创建人")
@@ -222,6 +245,20 @@ class MissionModelTestCase(TestCase):
self.assertEqual(self.mission.content_id, related.id) self.assertEqual(self.mission.content_id, related.id)
self.assertEqual(list(self.mission.participants.values_list("employee_id", flat=True)), [self.responder.id]) self.assertEqual(list(self.mission.participants.values_list("employee_id", flat=True)), [self.responder.id])
def test_update_mission_updates_unreplied_notification_fields(self):
update_mission(
mission=self.mission,
updated_by=self.creator,
notify_if_unreplied=True,
unreplied_notify_interval_minutes=20,
unreplied_notify_max_count=8,
)
self.mission.refresh_from_db()
self.assertTrue(self.mission.notify_if_unreplied)
self.assertEqual(self.mission.unreplied_notify_interval_minutes, 20)
self.assertEqual(self.mission.unreplied_notify_max_count, 8)
def test_create_mission_rejects_cross_merchant_category(self): def test_create_mission_rejects_cross_merchant_category(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
create_mission( create_mission(
@@ -340,6 +377,57 @@ class MissionModelTestCase(TestCase):
self.mission.refresh_from_db() self.mission.refresh_from_db()
self.assertTrue(self.mission.is_completed) self.assertTrue(self.mission.is_completed)
def test_create_reply_resets_unreplied_notification_state(self):
self.mission.notify_if_unreplied = True
self.mission.unreplied_notify_interval_minutes = 10
self.mission.unreplied_notify_sent_count = 3
self.mission.unreplied_last_notified_at = timezone.now()
self.mission.save(
update_fields=[
"notify_if_unreplied",
"unreplied_notify_interval_minutes",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"updated_at",
]
)
create_mission_reply(
mission=self.mission,
responder=self.responder,
content="收到,处理中",
)
self.mission.refresh_from_db()
self.assertEqual(self.mission.unreplied_notify_sent_count, 0)
self.assertIsNone(self.mission.unreplied_last_notified_at)
def test_reject_reply_without_other_effective_reply_resets_unreplied_notification_state(self):
self.mission.notify_if_unreplied = True
self.mission.unreplied_notify_interval_minutes = 10
self.mission.unreplied_notify_sent_count = 2
self.mission.unreplied_last_notified_at = timezone.now()
self.mission.save(
update_fields=[
"notify_if_unreplied",
"unreplied_notify_interval_minutes",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"updated_at",
]
)
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="普通回应",
)
reject_reply(reply=reply, rejected_by=self.creator)
self.mission.refresh_from_db()
self.assertEqual(self.mission.unreplied_notify_sent_count, 0)
self.assertIsNone(self.mission.unreplied_last_notified_at)
def test_cancel_already_cancelled_mission_is_rejected(self): def test_cancel_already_cancelled_mission_is_rejected(self):
cancel_mission(mission=self.mission, cancelled_by=self.creator) cancel_mission(mission=self.mission, cancelled_by=self.creator)

View File

@@ -13,6 +13,7 @@ class NotificationEventKeyEnum(models.TextChoices):
MISSION_CREATED = "mission.created", "任务已创建" MISSION_CREATED = "mission.created", "任务已创建"
MISSION_REPLIED = "mission.replied", "任务有新回应" MISSION_REPLIED = "mission.replied", "任务有新回应"
MISSION_COMPLETED = "mission.completed", "任务已完成" MISSION_COMPLETED = "mission.completed", "任务已完成"
MISSION_UNREPLIED = "mission.unreplied", "任务未回复提醒"
MISSION_REPLY_REJECTED = "mission.reply_rejected", "任务回应已撤销" MISSION_REPLY_REJECTED = "mission.reply_rejected", "任务回应已撤销"
MISSION_REOPENED = "mission.reopened", "任务已重新打开" MISSION_REOPENED = "mission.reopened", "任务已重新打开"
MISSION_CANCELLED = "mission.cancelled", "任务已取消" MISSION_CANCELLED = "mission.cancelled", "任务已取消"

View File

@@ -1,12 +1,124 @@
import logging import logging
from typing import Any
from celery import shared_task from celery import shared_task
from django.db import transaction
from django.db.models import Exists, F, OuterRef
from django.utils import timezone
from mission import models as mission_models
from mission import services as mission_services
from notifier.models import NotificationEventKeyEnum
from notifier.services import dispatch_notification_event from notifier.services import dispatch_notification_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notified_at) -> dict[str, Any]:
participant_names = list(
mission.get_participants().values_list("employee__name", flat=True)
)
content_type = getattr(mission.content_type, "model", None)
next_count = mission.unreplied_notify_sent_count + 1
return {
"mission_id": mission.id,
"merchant_id": mission.merchant_id,
"description": mission.description,
"category_id": mission.category_id,
"category_name": mission.category.name,
"is_urgent": mission.is_urgent,
"is_completed": mission.is_completed,
"is_cancelled": mission.is_cancelled,
"creator_id": mission.creator_id,
"creator_name": getattr(mission.creator, "name", ""),
"participant_names": participant_names,
"participant_names_display": "".join(participant_names) if participant_names else "",
"content_type": content_type or "",
"content_id": mission.content_id or "",
"unreplied_notify_interval_minutes": mission.unreplied_notify_interval_minutes,
"unreplied_notify_max_count": mission.unreplied_notify_max_count,
"unreplied_notify_sent_count": next_count,
"unreplied_last_notified_at": notified_at.isoformat() if notified_at else "",
}
def _get_due_unreplied_mission_ids(*, limit: int) -> list[int]:
effective_reply_exists = mission_models.MissionReply.objects.filter(
mission_id=OuterRef("pk"),
is_rejected=False,
)
queryset = (
mission_models.Mission.objects.filter(
notify_if_unreplied=True,
is_completed=False,
is_cancelled=False,
unreplied_notify_interval_minutes__gt=0,
)
.filter(unreplied_notify_sent_count__lt=F("unreplied_notify_max_count"))
.annotate(has_effective_reply=Exists(effective_reply_exists))
.filter(has_effective_reply=False)
.order_by("id")
)
return list(queryset.values_list("id", flat=True)[:limit])
def _dispatch_unreplied_notification_for_mission(*, mission_id: int) -> dict[str, Any]:
with transaction.atomic():
mission = (
mission_models.Mission.objects.select_for_update(skip_locked=True)
.select_related("category", "creator")
.prefetch_related("participants__employee")
.filter(id=mission_id)
.first()
)
if mission is None:
return {"mission_id": mission_id, "status": "missing"}
if not mission.notify_if_unreplied:
return {"mission_id": mission.id, "status": "disabled"}
if mission.is_completed or mission.is_cancelled:
return {"mission_id": mission.id, "status": "inactive"}
if mission.unreplied_notify_interval_minutes is None or mission.unreplied_notify_interval_minutes <= 0:
return {"mission_id": mission.id, "status": "invalid-interval"}
if mission.unreplied_notify_sent_count >= mission.unreplied_notify_max_count:
return {"mission_id": mission.id, "status": "maxed"}
if mission.replies.filter(is_rejected=False).exists():
return {"mission_id": mission.id, "status": "has-reply"}
now = timezone.now()
due_at = mission_services.get_mission_unreplied_due_at(mission=mission)
if due_at is None or now < due_at:
return {
"mission_id": mission.id,
"status": "not-due",
"due_at": due_at.isoformat() if due_at else None,
}
results = dispatch_notification_event(
event_key=NotificationEventKeyEnum.MISSION_UNREPLIED,
merchant_id=mission.merchant_id,
payload=_build_unreplied_mission_payload(mission=mission, notified_at=now),
)
if not results:
return {"mission_id": mission.id, "status": "no-route"}
mission.unreplied_last_notified_at = now
mission.unreplied_notify_sent_count += 1
mission.save(
update_fields=[
"unreplied_last_notified_at",
"unreplied_notify_sent_count",
"updated_at",
]
)
return {
"mission_id": mission.id,
"status": "sent",
"results": results,
"sent_count": mission.unreplied_notify_sent_count,
}
@shared_task(bind=True) @shared_task(bind=True)
def dispatch_notification_event_task(self, *, event_key: str, merchant_id: int, payload: dict | None = None) -> dict: def dispatch_notification_event_task(self, *, event_key: str, merchant_id: int, payload: dict | None = None) -> dict:
results = dispatch_notification_event( results = dispatch_notification_event(
@@ -25,3 +137,21 @@ def dispatch_notification_event_task(self, *, event_key: str, merchant_id: int,
} }
logger.info("[notifier.tasks] notification task finished: %s", summary) logger.info("[notifier.tasks] notification task finished: %s", summary)
return summary return summary
@shared_task(bind=True)
def notify_unreplied_missions_task(self, *, limit: int = 100) -> dict:
mission_ids = _get_due_unreplied_mission_ids(limit=max(1, int(limit)))
items = [
_dispatch_unreplied_notification_for_mission(mission_id=mission_id)
for mission_id in mission_ids
]
summary = {
"task_id": self.request.id,
"candidate_count": len(mission_ids),
"processed_count": len(items),
"sent_count": sum(1 for item in items if item.get("status") == "sent"),
"items": items,
}
logger.info("[notifier.tasks] unreplied mission notify task finished: %s", summary)
return summary

View File

@@ -0,0 +1,9 @@
任务未回复提醒
任务ID{{ mission_id }}
分类:{{ category_name }}
创建人:{{ creator_name }}
参与者:{{ participant_names_display }}
提醒间隔:{{ unreplied_notify_interval_minutes }} 分钟
提醒次数:{{ unreplied_notify_sent_count }}/{{ unreplied_notify_max_count }}
任务描述:{{ description }}

View File

@@ -1,9 +1,10 @@
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from basic_info.models import Merchant, MerchantTypeEnum from basic_info.models import Employee, Merchant, MerchantTypeEnum
from mission.models import MissionCategory from mission.models import Mission, MissionCategory, MissionReply
from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute
from notifier.services import ( from notifier.services import (
dispatch_notification_event, dispatch_notification_event,
@@ -11,6 +12,7 @@ from notifier.services import (
render_notification_content, render_notification_content,
send_notification_with_notifier, send_notification_with_notifier,
) )
from notifier.tasks import notify_unreplied_missions_task
class NotifierServiceTestCase(TestCase): class NotifierServiceTestCase(TestCase):
@@ -30,6 +32,7 @@ class NotifierServiceTestCase(TestCase):
notifier=self.notifier, notifier=self.notifier,
event_key=NotificationEventKeyEnum.MISSION_CREATED, event_key=NotificationEventKeyEnum.MISSION_CREATED,
) )
self.creator = Employee.objects.create(merchant=self.merchant, name="任务创建者")
def test_render_notification_content(self): def test_render_notification_content(self):
content = render_notification_content( content = render_notification_content(
@@ -189,3 +192,63 @@ class NotifierServiceTestCase(TestCase):
self.assertIsNone(task_id) self.assertIsNone(task_id)
mock_delay.assert_called_once() mock_delay.assert_called_once()
@patch("notifier.tasks.dispatch_notification_event")
def test_notify_unreplied_missions_task_dispatches_due_mission_and_updates_state(self, mock_dispatch):
mock_dispatch.return_value = [{"status": "sent"}]
notifier = Notifier.objects.create(
merchant=self.merchant,
name="未回复提醒通知器",
channel=NotifierChannelEnum.WECOM_WEBHOOK,
template_key="mission_unreplied",
config={"key": "unreplied-key"},
)
NotifierRoute.objects.create(
merchant=self.merchant,
notifier=notifier,
event_key=NotificationEventKeyEnum.MISSION_UNREPLIED,
)
mission = Mission.objects.create(
merchant=self.merchant,
category=self.general_category,
creator=self.creator,
description="超时未回复任务",
notify_if_unreplied=True,
unreplied_notify_interval_minutes=1,
)
mission.created_at = timezone.now() - timezone.timedelta(minutes=3)
mission.save(update_fields=["created_at", "updated_at"])
result = notify_unreplied_missions_task.run(limit=10)
mission.refresh_from_db()
self.assertEqual(result["sent_count"], 1)
self.assertEqual(mission.unreplied_notify_sent_count, 1)
self.assertIsNotNone(mission.unreplied_last_notified_at)
mock_dispatch.assert_called_once()
self.assertEqual(mock_dispatch.call_args.kwargs["event_key"], NotificationEventKeyEnum.MISSION_UNREPLIED)
@patch("notifier.tasks.dispatch_notification_event")
def test_notify_unreplied_missions_task_skips_mission_with_effective_reply(self, mock_dispatch):
mission = Mission.objects.create(
merchant=self.merchant,
category=self.general_category,
creator=self.creator,
description="已有回复任务",
notify_if_unreplied=True,
unreplied_notify_interval_minutes=1,
)
mission.created_at = timezone.now() - timezone.timedelta(minutes=3)
mission.save(update_fields=["created_at", "updated_at"])
responder = Employee.objects.create(merchant=self.merchant, name="回应人")
MissionReply.objects.create(
merchant=self.merchant,
mission=mission,
responder=responder,
content="收到",
)
result = notify_unreplied_missions_task.run(limit=10)
self.assertEqual(result["sent_count"], 0)
mock_dispatch.assert_not_called()

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2026-04-17 00:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shipment', '0021_geo_coordinates'),
]
operations = [
migrations.AddField(
model_name='shipment',
name='rejected_sales_item_ids',
field=models.JSONField(blank=True, default=list, help_text='仅用于审计记录出货单驳回前所绑定的销售品ID列表', verbose_name='驳回前销售品ID快照'),
),
]

View File

@@ -205,6 +205,13 @@ class Shipment(ModelBase):
help_text='由 Geo 系统返回的坐标信息,结构由调用方决定,后端不作校验', help_text='由 Geo 系统返回的坐标信息,结构由调用方决定,后端不作校验',
) )
rejected_sales_item_ids = models.JSONField(
default=list,
blank=True,
verbose_name='驳回前销售品ID快照',
help_text='仅用于审计记录出货单驳回前所绑定的销售品ID列表',
)
class Meta: class Meta:
db_table = 'shipment' db_table = 'shipment'
verbose_name = '出货单' verbose_name = '出货单'

View File

@@ -625,7 +625,7 @@ def modify_status(
规则: 规则:
- 草稿 -> 只能已发布 - 草稿 -> 只能已发布
- 已发布 -> 已审核 / 已驳回 / 已取消 - 已发布 -> 已审核 / 已驳回 / 已取消
- 已驳回 -> 已审核 / 已取消 - 已驳回 -> 已取消
- 已审核 -> 已取消 - 已审核 -> 已取消
- 已取消 -> 不可再变 - 已取消 -> 不可再变
- 重复设置同一状态保持幂等,直接返回 - 重复设置同一状态保持幂等,直接返回
@@ -642,7 +642,9 @@ def modify_status(
ShipmentStatus.CANCELLED, ShipmentStatus.CANCELLED,
}, },
ShipmentStatus.REJECTED: { ShipmentStatus.REJECTED: {
ShipmentStatus.APPROVED, # 驳回现在会解绑销售品并回退到待分配池。
# 在没有重新绑定流程前,暂时关闭 REJECTED -> APPROVED避免审核空出货单。
# ShipmentStatus.APPROVED,
ShipmentStatus.CANCELLED, ShipmentStatus.CANCELLED,
}, },
ShipmentStatus.APPROVED: { ShipmentStatus.APPROVED: {
@@ -660,6 +662,22 @@ def modify_status(
if target_status == ShipmentStatus.APPROVED and approved_by is None: if target_status == ShipmentStatus.APPROVED and approved_by is None:
raise ValueError("目标状态为已审核时approved_by 不能为空") raise ValueError("目标状态为已审核时approved_by 不能为空")
update_fields = [
"status",
"status_modified_at",
"cancelled_by",
"approved_by",
"updated_at",
]
if target_status == ShipmentStatus.REJECTED:
bound_sales_item_qs = SalesItem.objects.select_for_update().filter(shipment=shipment)
shipment.rejected_sales_item_ids = list(
bound_sales_item_qs.order_by("id").values_list("id", flat=True)
)
bound_sales_item_qs.update(shipment=None)
update_fields.append("rejected_sales_item_ids")
shipment.status = target_status shipment.status = target_status
shipment.status_modified_at = timezone.now() shipment.status_modified_at = timezone.now()
@@ -669,15 +687,7 @@ def modify_status(
if target_status == ShipmentStatus.APPROVED: if target_status == ShipmentStatus.APPROVED:
shipment.approved_by = approved_by shipment.approved_by = approved_by
shipment.save( shipment.save(update_fields=update_fields)
update_fields=[
"status",
"status_modified_at",
"cancelled_by",
"approved_by",
"updated_at",
]
)
return shipment return shipment