forked from erp-dev/erp
feat: mission replied calling task
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
114
docs/admin_mission_unreplied_notification_guide_2026-04-17.md
Normal file
114
docs/admin_mission_unreplied_notification_guide_2026-04-17.md
Normal 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` 对应的通知器和通知路由。
|
||||||
|
|
||||||
|
系统会每分钟扫描一次任务。只有当任务未完成、未取消、当前没有有效回复、且没超过最大提醒次数时,才会继续发送提醒。
|
||||||
|
|
||||||
|
一旦任务收到有效回复,未回复提醒会自动停止;如果回复后来被撤销,系统会重新开始新的未回复提醒周期。
|
||||||
|
```
|
||||||
@@ -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 重新回到“无有效回复”状态:
|
||||||
|
|
||||||
|
- 系统会把该任务视为一个新的未回复周期重新开始计时
|
||||||
|
|||||||
@@ -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 个关键点就够了:
|
||||||
|
|
||||||
|
|||||||
@@ -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. 测试覆盖重点
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
58
docs/shipment_rejected_frontend_note_2026-04-17.md
Normal file
58
docs/shipment_rejected_frontend_note_2026-04-17.md
Normal 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. `已驳回 -> 已审核` 已临时关闭,前端如有对应按钮请隐藏或禁用。
|
||||||
|
```
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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="未回复已提醒次数"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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", "任务已取消"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
9
notifier/templates/notifier/events/mission_unreplied.md
Normal file
9
notifier/templates/notifier/events/mission_unreplied.md
Normal 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 }}
|
||||||
@@ -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()
|
||||||
|
|||||||
18
shipment/migrations/0022_shipment_rejected_sales_item_ids.py
Normal file
18
shipment/migrations/0022_shipment_rejected_sales_item_ids.py
Normal 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快照'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 = '出货单'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user