From 8b1afc64c501a41bea0eb29af03ac27de570d283 Mon Sep 17 00:00:00 2001 From: colaftc Date: Fri, 17 Apr 2026 23:26:33 +0800 Subject: [PATCH] feat: mission replied calling task --- api_v1/views/shipment/test_api.py | 72 ++++++-- api_v2/test_mission_api.py | 51 ++++++ api_v2/views/mission.py | 46 ++++- ...unreplied_notification_guide_2026-04-17.md | 114 +++++++++++++ docs/api_v2_mission_api.md | 56 ++++++ docs/notifier_admin_guide.md | 56 +++++- docs/notifier_module_design.md | 71 ++------ docs/shipment_api.md | 21 ++- ...pment_rejected_frontend_note_2026-04-17.md | 58 +++++++ docs/shipment_status_api.md | 34 +++- flower/settings.py | 7 + ...5_mission_unreplied_notification_fields.py | 36 ++++ mission/models.py | 17 ++ mission/services.py | 160 +++++++++++++++++- mission/tests.py | 88 ++++++++++ notifier/models.py | 1 + notifier/tasks.py | 130 ++++++++++++++ .../notifier/events/mission_unreplied.md | 9 + notifier/tests.py | 67 +++++++- .../0022_shipment_rejected_sales_item_ids.py | 18 ++ shipment/models.py | 7 + shipment/services.py | 32 ++-- 22 files changed, 1056 insertions(+), 95 deletions(-) create mode 100644 docs/admin_mission_unreplied_notification_guide_2026-04-17.md create mode 100644 docs/shipment_rejected_frontend_note_2026-04-17.md create mode 100644 mission/migrations/0005_mission_unreplied_notification_fields.py create mode 100644 notifier/templates/notifier/events/mission_unreplied.md create mode 100644 shipment/migrations/0022_shipment_rejected_sales_item_ids.py diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 59004be..9a14e1c 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -1968,7 +1968,49 @@ class ShipmentStatusServiceTestCase(TestCase): self.assertEqual(self.shipment.approved_by, self.user) 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 modify_status( @@ -1981,16 +2023,14 @@ class ShipmentStatusServiceTestCase(TestCase): target_status=shipment_models.ShipmentStatus.REJECTED, 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() - self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.APPROVED) - self.assertEqual(self.shipment.approved_by, self.user) + with self.assertRaisesMessage(ValueError, "不允许将出货单状态从 已驳回 修改为 已审核"): + modify_status( + self.shipment, + target_status=shipment_models.ShipmentStatus.APPROVED, + operator=self.user, + approved_by=self.user, + ) def test_modify_status_rejects_rejected_to_published(self): from shipment.services import modify_status @@ -2146,6 +2186,15 @@ class ShipmentStatusAPITestCase(APITestCase): def test_patch_status_published_to_rejected_success(self): 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( self.shipment, target_status=shipment_models.ShipmentStatus.PUBLISHED, @@ -2160,7 +2209,10 @@ class ShipmentStatusAPITestCase(APITestCase): self.assertEqual(resp.status_code, status.HTTP_200_OK) self.shipment.refresh_from_db() + sales_item.refresh_from_db() 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): from shipment.services import modify_status diff --git a/api_v2/test_mission_api.py b/api_v2/test_mission_api.py index 557345c..3d75c9a 100644 --- a/api_v2/test_mission_api.py +++ b/api_v2/test_mission_api.py @@ -69,10 +69,42 @@ class MissionV2APITest(TestCase): self.assertFalse(mission.is_urgent) self.assertFalse(mission.is_completed) 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_name"], "通用") 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): resp = self.client.post( "/api/v2/missions/", @@ -146,6 +178,25 @@ class MissionV2APITest(TestCase): self.assertEqual(resp.data["category_name"], "跟进") 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): visible = self._create_mission(description="可见任务") mission_models.Mission.objects.create( diff --git a/api_v2/views/mission.py b/api_v2/views/mission.py index eed6d5b..43ee7ca 100644 --- a/api_v2/views/mission.py +++ b/api_v2/views/mission.py @@ -36,6 +36,9 @@ class MissionWriteSerializer(serializers.Serializer): category = serializers.IntegerField(required=False, 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) + 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( child=serializers.IntegerField(min_value=1), required=False, @@ -73,6 +76,20 @@ class MissionWriteSerializer(serializers.Serializer): attrs["content_type"] = ContentType.objects.get(id=content_type_id) except ContentType.DoesNotExist as 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 @@ -126,6 +143,11 @@ class MissionSerializer(serializers.ModelSerializer): "is_urgent", "is_completed", "is_cancelled", + "notify_if_unreplied", + "unreplied_notify_interval_minutes", + "unreplied_notify_max_count", + "unreplied_notify_sent_count", + "unreplied_last_notified_at", "cancelled_at", "creator", "cancelled_by", @@ -313,6 +335,9 @@ class MissionListCreateView(APIView): content_type=data.get("content_type"), content_id=data.get("content_id"), 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: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) @@ -333,7 +358,11 @@ class MissionDetailView(APIView): def patch(self, request, mission_id): employee = _get_employee(request) 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) data = serializer.validated_data try: @@ -346,6 +375,21 @@ class MissionDetailView(APIView): content_id=data.get("content_id"), update_content_object=("content_type" in request.data or "content_id" in request.data), 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: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/admin_mission_unreplied_notification_guide_2026-04-17.md b/docs/admin_mission_unreplied_notification_guide_2026-04-17.md new file mode 100644 index 0000000..811613e --- /dev/null +++ b/docs/admin_mission_unreplied_notification_guide_2026-04-17.md @@ -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` 对应的通知器和通知路由。 + +系统会每分钟扫描一次任务。只有当任务未完成、未取消、当前没有有效回复、且没超过最大提醒次数时,才会继续发送提醒。 + +一旦任务收到有效回复,未回复提醒会自动停止;如果回复后来被撤销,系统会重新开始新的未回复提醒周期。 +``` diff --git a/docs/api_v2_mission_api.md b/docs/api_v2_mission_api.md index 37ff42d..86394b0 100644 --- a/docs/api_v2_mission_api.md +++ b/docs/api_v2_mission_api.md @@ -23,6 +23,10 @@ | `rejected_by` | 撤销人,由后端写入 | | `rejected_at` | 撤销时间,由后端写入 | +说明: + +- 本次新增的“未回复提醒配置字段”不属于状态字段,允许通过普通创建/更新接口维护 + ## content_type 说明 `content_type` 和 `content_id` 是可选的"关联业务对象"字段,用于把任务挂靠到系统中的某个具体业务单据或对象上。 @@ -109,6 +113,11 @@ "is_urgent": false, "is_completed": false, "is_cancelled": false, + "notify_if_unreplied": false, + "unreplied_notify_interval_minutes": null, + "unreplied_notify_max_count": 5, + "unreplied_notify_sent_count": 0, + "unreplied_last_notified_at": null, "cancelled_at": null, "creator": { "id": 20, @@ -246,6 +255,9 @@ | `category` | int | 否 | 任务分类 ID;不传时默认使用当前商户下名称为“通用”的分类,不存在则自动创建 | | `content_type` | int/null | 否 | Django ContentType ID;必须与 `content_id` 同时提供或同时省略 | | `content_id` | int/null | 否 | 关联业务对象 ID;必须与 `content_type` 同时提供或同时省略 | +| `notify_if_unreplied` | boolean | 否 | 是否开启“未回复持续提醒”,默认 `false` | +| `unreplied_notify_interval_minutes` | int/null | 否 | 未回复提醒间隔(分钟);开启未回复提醒时必填 | +| `unreplied_notify_max_count` | int | 否 | 最大提醒次数,默认 `5` | | `participant_ids` | int[] | 否 | 参与者员工 ID 列表,必须属于当前商户 | 说明: @@ -254,6 +266,8 @@ - `category_name` 为只读字段,由后端根据分类表返回 - `is_urgent`、`is_completed`、`is_cancelled` 均按默认值创建,不接受请求参数 - 若关联对象存在 `merchant_id` 字段,后端会校验它必须属于当前商户 +- 若 `notify_if_unreplied=true`,则必须同时传入 `unreplied_notify_interval_minutes` +- `unreplied_notify_sent_count` 与 `unreplied_last_notified_at` 为只读运行时字段,由后端维护 请求示例: @@ -261,6 +275,9 @@ { "description": "跟进客户问题", "category": 1, + "notify_if_unreplied": true, + "unreplied_notify_interval_minutes": 30, + "unreplied_notify_max_count": 5, "participant_ids": [21, 22] } ``` @@ -289,10 +306,18 @@ | `category` | int | 任务分类 ID | | `content_type` | int/null | 关联对象类型;必须与 `content_id` 同时提供 | | `content_id` | int/null | 关联对象 ID;必须与 `content_type` 同时提供 | +| `notify_if_unreplied` | boolean | 是否开启“未回复持续提醒” | +| `unreplied_notify_interval_minutes` | int/null | 未回复提醒间隔(分钟) | +| `unreplied_notify_max_count` | int | 最大提醒次数 | | `participant_ids` | int[] | 重置参与者列表 | 禁止更新状态字段,见“基本约定” +补充说明: + +- 更新 `notify_if_unreplied`、`unreplied_notify_interval_minutes` 时,后端会重置当前任务的未回复提醒计数与上次提醒时间 +- 更新 `unreplied_notify_max_count` 不会重置已提醒次数 + 成功响应:`Mission` ## 删除任务 @@ -390,6 +415,7 @@ HTTP 状态码:`405 Method Not Allowed` - `Mission.cancelled_by=当前员工` - `Mission.cancelled_at=当前时间` - 不强行修改 `Mission.is_completed` +- 后端会停止当前任务后续的未回复提醒,并保留任务上的提醒历史字段供查看 成功响应:`Mission` @@ -431,7 +457,37 @@ HTTP 状态码:`405 Method Not Allowed` - 当前回应标记为 `is_rejected=true` - 当前回应的 `rejected_by`、`rejected_at` 由后端写入 - 如果当前回应原本是唯一有效的结束回应,则对应任务会被重新置为未完成 +- 如果任务重新回到“没有任何有效回复”的状态,后端会重置未回复提醒计数,并从新的空窗期重新开始计算后续提醒 成功响应:`MissionReply` 无权限响应:`403 Forbidden` + +## 未回复提醒规则 + +当任务开启 `notify_if_unreplied=true` 时,系统会通过每分钟一次的后台定时任务检查是否需要发送“未回复提醒”。 + +判定规则: + +1. 任务开启了未回复提醒 +2. 任务未完成 +3. 任务未取消 +4. 任务当前没有任何有效回复(`is_rejected=false` 的回复) +5. `unreplied_notify_sent_count < unreplied_notify_max_count` +6. 到达提醒时间: + - 从未提醒过:`created_at + unreplied_notify_interval_minutes` + - 已提醒过:`unreplied_last_notified_at + unreplied_notify_interval_minutes` + +发送成功后: + +- `unreplied_notify_sent_count` 自增 1 +- `unreplied_last_notified_at` 更新为本次成功发送时间 + +收到有效回复后: + +- 当前未回复提醒周期会被停止 +- `unreplied_notify_sent_count` 与 `unreplied_last_notified_at` 会被重置 + +如果后续因为撤销回复或 reopen 重新回到“无有效回复”状态: + +- 系统会把该任务视为一个新的未回复周期重新开始计时 diff --git a/docs/notifier_admin_guide.md b/docs/notifier_admin_guide.md index 7d89ef2..266625d 100644 --- a/docs/notifier_admin_guide.md +++ b/docs/notifier_admin_guide.md @@ -21,6 +21,7 @@ - `mission.created` - `mission.replied` - `mission.completed` +- `mission.unreplied` - `mission.reply_rejected` - `mission.reopened` - `mission.cancelled` @@ -144,6 +145,7 @@ - `mission.created` - `mission.completed` +- `mission.unreplied` ### 6.4 mission_category @@ -254,6 +256,7 @@ - `mission_created` - `mission_replied` - `mission_completed` +- `mission_unreplied` - `mission_reply_rejected` - `mission_reopened` - `mission_cancelled` @@ -261,7 +264,52 @@ 管理人员通常只需要填 `template_key`,不需要改代码。 如果后续要新增模板内容或调整文案,需要由开发人员修改模板文件。 -## 10. 如何停用 +## 10. 未回复提醒的 admin 配置要点 + +`mission.unreplied` 和其它事件不同,它不是在某个瞬时动作发生时触发,而是由后台每分钟扫描一次“仍未回复的任务”后触发。 + +这意味着 admin 需要同时确认两件事: + +1. 任务本身开启了未回复提醒 +2. 通知系统中存在 `mission.unreplied` 对应的 `NotifierRoute` + +如果只配置了路由,但任务没有开启提醒,则不会发送。 + +如果任务开启了提醒,但没有配置 `mission.unreplied` 路由,也不会发送到任何群。 + +### 10.1 推荐配置方式 + +1. 创建一个 `Notifier` + - `name = 任务未回复提醒-管理群` + - `channel = wecom_webhook` + - `template_key = mission_unreplied` + - `is_enabled = True` +2. 创建一条 `NotifierRoute` + - `event_key = mission.unreplied` + - `mission_category = 留空` 或选择具体任务分类 + - `is_enabled = True` + +### 10.2 什么时候会持续提醒 + +只有满足以下条件才会持续发送 `mission.unreplied`: + +1. 任务开启了“未回复提醒” +2. 任务还没完成 +3. 任务还没取消 +4. 当前没有任何有效回复 +5. 没超过任务自身设置的最大提醒次数 + +### 10.3 为什么任务开启了提醒,但还是没收到群通知 + +优先检查: + +1. 是否已配置 `mission.unreplied` 的路由 +2. 该路由是否启用 +3. 对应的 `Notifier` 是否启用 +4. `template_key` 是否写成 `mission_unreplied` +5. Celery worker / beat 是否都在运行 + +## 11. 如何停用 ### 停用整个通知器 @@ -290,7 +338,7 @@ 2. 取消勾选 `is_enabled` 3. 保存 -## 11. 常见问题 +## 12. 常见问题 ### 11.1 为什么事件发生了,但没有收到通知 @@ -322,7 +370,7 @@ 不会。 系统会自动去重,并优先使用更具体的分类路由。 -## 12. 管理建议 +## 13. 管理建议 - 先建 `Notifier`,再建 `NotifierRoute` - 通知器名称中写清楚目标群 @@ -330,7 +378,7 @@ - 先停用再删除 - 先配置一条路由做验证,再批量扩展 -## 13. 最简操作结论 +## 14. 最简操作结论 如果你只想快速配置一条通知,记住这 6 个关键点就够了: diff --git a/docs/notifier_module_design.md b/docs/notifier_module_design.md index 8713bb6..cc7571d 100644 --- a/docs/notifier_module_design.md +++ b/docs/notifier_module_design.md @@ -41,49 +41,27 @@ ### 3.1 Notifier -`Notifier` 负责“怎么发”: - -- `merchant` -- `name` -- `channel` -- `template_key` -- `is_enabled` - `config` - `description` 当前 `Notifier` 已不再直接持有 `event_key`。 -### 3.2 NotifierRoute `NotifierRoute` 负责“何时发、发给谁”: - `merchant` - `notifier` -- `event_key` -- `mission_category` -- `is_enabled` -- `description` - 其中: - `mission_category = null` 表示该事件的通配路由 -- `mission_category != null` 表示任务分类专用路由 - -### 3.3 约束设计 - -当前约束: - `Notifier` 在同商户下 `name` 唯一 - `NotifierRoute` 在同一 `notifier + event_key + mission_category` 下唯一 - `NotifierRoute` 额外限制同一 `notifier + event_key` 只能有一条通配路由 -## 4. 路由匹配规则 - 当前 `dispatch_notification_event(...)` 的匹配规则为: 1. 先按 `merchant + event_key + route.is_enabled=True + notifier.is_enabled=True` 查路由 -2. 如果 payload 中带有 `category_id` - - 匹配该分类的专用路由 - 也允许匹配通配路由 3. 如果 payload 中没有 `category_id` - 只匹配通配路由 @@ -91,11 +69,6 @@ - 只保留一条 - 优先保留专用路由 -这样可以同时满足: - -- 分类专用通知 -- 默认兜底通知 -- 多群并发通知 - 同一通知器不重复发送 ## 5. 当前调用链 @@ -103,32 +76,14 @@ 通知链路如下: 1. `mission.services` 在事务提交后发 signal -2. `mission.handlers` 构造 payload -3. payload 中已包含 `category_id`、`category_name` -4. handler 调用 `enqueue_notification_event(...)` -5. Celery task 调用 `dispatch_notification_event(...)` -6. notifier 根据 route 匹配命中的 `Notifier` -7. 渲染模板并调用 backend 发送 -8. 记录路由日志和发送日志 - ## 6. 已接入的事件 当前 `mission` 已接入: -- `mission.created` -- `mission.replied` -- `mission.completed` -- `mission.reply_rejected` -- `mission.reopened` -- `mission.cancelled` - -这些事件全部支持按任务分类路由。 - +- `mission.unreplied` +- `mission.unreplied` 并非由业务 signal 直接触发,而是由后台每分钟一次的扫描任务按任务对象上的提醒配置触发 ## 7. 日志策略 -当前日志覆盖以下节点: - -- 事件入队 - 没有命中任何可用路由 - 路由命中成功 - backend 发送成功 @@ -146,26 +101,26 @@ 当前后台提供两个对象: -- `Notifier` -- `NotifierRoute` 并且: -- `Notifier` 页面支持 inline 维护其下路由 -- `NotifierRoute` 也支持单独管理 在 `Notifier` inline 场景下,route 的 `merchant` 会自动同步为当前 notifier 的商户,避免管理人员重复录入。 +## 11. Mission 上的未回复提醒状态字段 -## 9. 迁移策略 +当前未回复提醒的核心状态全部放在 `Mission` 对象自身: -本次从旧结构迁到新结构时,做了自动回填: +- `notify_if_unreplied` +- `unreplied_notify_interval_minutes` +- `unreplied_notify_max_count` +- `unreplied_notify_sent_count` +- `unreplied_last_notified_at` -- 对每条旧 `Notifier(event_key=...)` -- 自动创建一条 `NotifierRoute` -- `event_key` 原样继承 -- `mission_category = null` -- `description` 标记为自动迁移生成 +这样做的原因是: +- 配置和运行态统一放在任务对象上,最容易排查 +- 不需要额外的提醒计划表或提醒历史表就能支撑当前需求 +- 最大提醒次数和上次提醒时间都能直接在任务详情中观察到 这样旧配置不会因为结构调整而丢失。 ## 10. 测试覆盖重点 diff --git a/docs/shipment_api.md b/docs/shipment_api.md index 4fddf44..3112190 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -77,6 +77,7 @@ - `sales_items` 现在返回的是带图片字段的销售品详情结构 - 若销售品关联的 `PrintingJob.product` 存在主图,则会返回 `product_image_url` - 若无关联图片,则 `product_image_url` 为 `null` +- 若出货单已经被驳回,则其销售品会在驳回时被自动解绑,因此此处的 `sales_items` 会变为空数组 ### 出货单详情 @@ -237,15 +238,21 @@ | created_at | string | 创建时间 | | updated_at | string | 更新时间 | +说明: + +- 后端内部会在驳回时记录 `rejected_sales_item_ids` 审计快照,但该字段当前不对前端返回 + ### 状态流转规则 - `草稿(未发布)` 只能流转到 `已发布` - `已发布` 可以流转到 `已审核` / `已驳回` / `已取消` -- `已驳回` 可以流转到 `已审核` / `已取消` +- `已驳回` 目前只能流转到 `已取消` - `已审核` 可以流转到 `已取消` - `已取消` 不可再流转到其他状态 - 重复设置同一状态保持幂等,不报错 - 进入 `已审核` 时必须提供审核人 +- 进入 `已驳回` 时,系统会自动解绑当前出货单下的销售品,并将它们退回待分配池 +- 驳回时解绑前的销售品 ID 会保存到后端审计字段 `rejected_sales_item_ids` ### 错误响应 @@ -426,6 +433,10 @@ 3. 只返回至少拥有 1 条未出货销售品的客户 4. 返回客户基础信息及未出货销售品数量 +补充说明: + +- 若某销售品原先绑定在一个出货单上,而该出货单后来被驳回,这些销售品会被自动解绑,因此会重新计入这里的“未出货销售品”统计 + ### 响应格式 ```json @@ -493,6 +504,10 @@ 4. 可通过 `include_already_has_shipment=true` 包含已出货销售品 5. 可通过 `external_order_id` 按关联生产订单的外部订单号精确筛选 +补充说明: + +- 被驳回出货单解绑的销售品会重新满足 `shipment = null` 条件,因此默认查询会再次返回它们 + ### 响应格式 ```json @@ -660,6 +675,10 @@ curl -X GET \ - `false`(默认):只返回 `shipment` 为空的销售品(待出货) - `true`:返回所有销售品(包含已出货的) +补充说明: + +- 若某销售品所在出货单已被驳回,该销售品会在驳回时自动解绑,因此默认查询会再次将其视为待出货销售品 + ### 响应格式 ```json diff --git a/docs/shipment_rejected_frontend_note_2026-04-17.md b/docs/shipment_rejected_frontend_note_2026-04-17.md new file mode 100644 index 0000000..72916cf --- /dev/null +++ b/docs/shipment_rejected_frontend_note_2026-04-17.md @@ -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. `已驳回 -> 已审核` 已临时关闭,前端如有对应按钮请隐藏或禁用。 +``` \ No newline at end of file diff --git a/docs/shipment_status_api.md b/docs/shipment_status_api.md index 898ccf6..1d66e8d 100644 --- a/docs/shipment_status_api.md +++ b/docs/shipment_status_api.md @@ -13,6 +13,9 @@ - 该接口只负责修改 `Shipment.status` - 业务字段修改仍然使用 `/api/v1/shipment/shipments/{id}/` - `PATCH` 与 `PUT` 当前行为一致,都是按请求体中的 `status` 执行状态流转 +- 当前驳回逻辑有额外副作用:会解绑该出货单当前绑定的销售品,使其重新回到待分配池 +- 驳回时后端会把解绑前的销售品 ID 保存到内部审计字段 `rejected_sales_item_ids` +- `rejected_sales_item_ids` 当前仅用于后端审计,暂不在 API 响应中返回 ## 请求体 @@ -35,7 +38,7 @@ - `草稿(未发布)` 只能流转到 `已发布` - `已发布` 可以流转到 `已审核` / `已驳回` / `已取消` -- `已驳回` 可以流转到 `已审核` / `已取消` +- `已驳回` 目前只能流转到 `已取消` - `已审核` 可以流转到 `已取消` - `已取消` 不可再流转 - 重复设置同一状态时保持幂等 @@ -44,8 +47,14 @@ - 进入 `已审核` 时,接口会自动将当前 `request.user` 写入 `approved_by` - 进入 `已取消` 时,接口会自动将当前 `request.user` 写入 `cancelled_by` +- 进入 `已驳回` 时,接口会先记录当前绑定销售品 ID 的审计快照,再解除这些销售品与当前出货单的绑定 +- 驳回完成后,这些销售品会重新出现在默认的“待出货/待分配”查询结果中 - 每次成功状态流转都会更新 `status_modified_at` +说明: + +- 由于驳回现在会解绑销售品,`已驳回 -> 已审核` 已暂时关闭,避免出现“空出货单被审核”的状态语义冲突 + ## 请求示例 ### 1. 草稿发布 @@ -70,6 +79,17 @@ Content-Type: application/json } ``` +### 3. 已发布驳回 + +```http +PATCH /api/v1/shipment/shipments/12/status/ +Content-Type: application/json + +{ + "status": 4 +} +``` + ## 成功响应示例 ```json @@ -113,7 +133,15 @@ Content-Type: application/json } ``` -### 2. 越权或对象不存在 +### 2. 已驳回后再次审核 + +```json +{ + "detail": "不允许将出货单状态从 已驳回 修改为 已审核" +} +``` + +### 3. 越权或对象不存在 ```json { @@ -121,7 +149,7 @@ Content-Type: application/json } ``` -### 3. 请求体不合法 +### 4. 请求体不合法 ```json { diff --git a/flower/settings.py b/flower/settings.py index f076a99..c0dab9b 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -606,6 +606,13 @@ CELERY_BEAT_SCHEDULE = { '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': { 'task': 'api_v1.tasks.backfill_external_product_images', 'schedule': crontab(minute=17), diff --git a/mission/migrations/0005_mission_unreplied_notification_fields.py b/mission/migrations/0005_mission_unreplied_notification_fields.py new file mode 100644 index 0000000..ae76e3c --- /dev/null +++ b/mission/migrations/0005_mission_unreplied_notification_fields.py @@ -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="未回复已提醒次数"), + ), + ] \ No newline at end of file diff --git a/mission/models.py b/mission/models.py index 367bd5a..ce1443c 100644 --- a/mission/models.py +++ b/mission/models.py @@ -50,6 +50,19 @@ class Mission(ModelBase): is_urgent = 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="是否取消") + 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="取消时间") creator = models.ForeignKey( "basic_info.Employee", @@ -84,6 +97,10 @@ class Mission(ModelBase): def can_reply(self) -> bool: 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): return self.participants.select_related("employee") diff --git a/mission/services.py b/mission/services.py index 28853ca..dab11ed 100644 --- a/mission/services.py +++ b/mission/services.py @@ -1,4 +1,5 @@ import logging +from datetime import timedelta from django.db import transaction from django.utils import timezone @@ -16,6 +17,7 @@ from mission.signals import ( logger = logging.getLogger(__name__) DEFAULT_MISSION_CATEGORY_NAME = "通用" +UNSET = object() 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("关联业务对象不属于当前商户") +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 def set_mission_participants(*, mission: Mission, participant_ids: list[int]) -> Mission: mission = Mission.objects.select_for_update().get(pk=mission.pk) @@ -101,12 +138,20 @@ def create_mission( content_type=None, content_id: 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: if creator is None: raise ValueError("任务创建者不能为空") merchant = creator.merchant _validate_content_object_merchant(content_type=content_type, content_id=content_id, 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( merchant=merchant, @@ -115,6 +160,9 @@ def create_mission( category=category or _get_default_mission_category(merchant=merchant), content_type=content_type, 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: set_mission_participants(mission=mission, participant_ids=participant_ids) @@ -138,6 +186,9 @@ def update_mission( content_id: int | None = None, update_content_object: bool = False, participant_ids: list[int] | None = None, + notify_if_unreplied=UNSET, + unreplied_notify_interval_minutes=UNSET, + unreplied_notify_max_count=UNSET, ) -> Mission: mission = Mission.objects.select_for_update().get(pk=mission.pk) _assert_employee_belongs_to_mission(updated_by, mission, "更新任务的员工") @@ -160,8 +211,55 @@ def update_mission( mission.content_id = 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: - mission.save(update_fields=[*update_fields, "updated_at"]) + mission.save(update_fields=[*dict.fromkeys(update_fields), "updated_at"]) if participant_ids is not None: set_mission_participants(mission=mission, participant_ids=participant_ids) return mission @@ -189,7 +287,15 @@ def create_mission_reply( ) if ends_task and not mission.is_completed: 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( mission_completed, sender=Mission, @@ -197,6 +303,8 @@ def create_mission_reply( completed_by=responder, reply=reply, ) + else: + _clear_unreplied_notification_state_if_active(mission=mission) _send_signal_on_commit( mission_replied, sender=MissionReply, @@ -223,7 +331,18 @@ def reopen_mission(*, mission: Mission, reopened_by) -> Mission: now = timezone.now() ending_replies.update(ends_task=False, is_rejected=True, rejected_by=reopened_by, rejected_at=now) 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): _send_signal_on_commit( 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"]) 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: 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( mission_reply_rejected, sender=MissionReply, @@ -288,7 +421,17 @@ def cancel_mission(*, mission: Mission, cancelled_by) -> Mission: mission.is_cancelled = True mission.cancelled_by = cancelled_by 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( mission_cancelled, sender=Mission, @@ -306,3 +449,10 @@ def set_mission_urgent(*, mission: Mission, updated_by, is_urgent: bool) -> Miss mission.is_urgent = is_urgent mission.save(update_fields=["is_urgent", "updated_at"]) 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) diff --git a/mission/tests.py b/mission/tests.py index b4a583c..3a75d16 100644 --- a/mission/tests.py +++ b/mission/tests.py @@ -1,6 +1,7 @@ from django.test import TestCase from django.contrib.contenttypes.models import ContentType from unittest.mock import patch +from django.utils import timezone from basic_info.models import Employee, Merchant, MerchantTypeEnum from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply @@ -160,6 +161,28 @@ class MissionModelTestCase(TestCase): self.assertFalse(mission.is_urgent) 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): with self.assertRaises(ValueError): create_mission(creator=None, description="无创建人") @@ -222,6 +245,20 @@ class MissionModelTestCase(TestCase): self.assertEqual(self.mission.content_id, related.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): with self.assertRaises(ValueError): create_mission( @@ -340,6 +377,57 @@ class MissionModelTestCase(TestCase): self.mission.refresh_from_db() 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): cancel_mission(mission=self.mission, cancelled_by=self.creator) diff --git a/notifier/models.py b/notifier/models.py index b753fe0..65b7ac6 100644 --- a/notifier/models.py +++ b/notifier/models.py @@ -13,6 +13,7 @@ class NotificationEventKeyEnum(models.TextChoices): MISSION_CREATED = "mission.created", "任务已创建" MISSION_REPLIED = "mission.replied", "任务有新回应" MISSION_COMPLETED = "mission.completed", "任务已完成" + MISSION_UNREPLIED = "mission.unreplied", "任务未回复提醒" MISSION_REPLY_REJECTED = "mission.reply_rejected", "任务回应已撤销" MISSION_REOPENED = "mission.reopened", "任务已重新打开" MISSION_CANCELLED = "mission.cancelled", "任务已取消" diff --git a/notifier/tasks.py b/notifier/tasks.py index 2449c11..b7225e6 100644 --- a/notifier/tasks.py +++ b/notifier/tasks.py @@ -1,12 +1,124 @@ import logging +from typing import Any 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 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) def dispatch_notification_event_task(self, *, event_key: str, merchant_id: int, payload: dict | None = None) -> dict: 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) 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 diff --git a/notifier/templates/notifier/events/mission_unreplied.md b/notifier/templates/notifier/events/mission_unreplied.md new file mode 100644 index 0000000..58c600b --- /dev/null +++ b/notifier/templates/notifier/events/mission_unreplied.md @@ -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 }} \ No newline at end of file diff --git a/notifier/tests.py b/notifier/tests.py index 51f6b74..11dc5ed 100644 --- a/notifier/tests.py +++ b/notifier/tests.py @@ -1,9 +1,10 @@ from unittest.mock import patch from django.test import TestCase +from django.utils import timezone -from basic_info.models import Merchant, MerchantTypeEnum -from mission.models import MissionCategory +from basic_info.models import Employee, Merchant, MerchantTypeEnum +from mission.models import Mission, MissionCategory, MissionReply from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute from notifier.services import ( dispatch_notification_event, @@ -11,6 +12,7 @@ from notifier.services import ( render_notification_content, send_notification_with_notifier, ) +from notifier.tasks import notify_unreplied_missions_task class NotifierServiceTestCase(TestCase): @@ -30,6 +32,7 @@ class NotifierServiceTestCase(TestCase): notifier=self.notifier, event_key=NotificationEventKeyEnum.MISSION_CREATED, ) + self.creator = Employee.objects.create(merchant=self.merchant, name="任务创建者") def test_render_notification_content(self): content = render_notification_content( @@ -189,3 +192,63 @@ class NotifierServiceTestCase(TestCase): self.assertIsNone(task_id) 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() diff --git a/shipment/migrations/0022_shipment_rejected_sales_item_ids.py b/shipment/migrations/0022_shipment_rejected_sales_item_ids.py new file mode 100644 index 0000000..da13ac2 --- /dev/null +++ b/shipment/migrations/0022_shipment_rejected_sales_item_ids.py @@ -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快照'), + ), + ] \ No newline at end of file diff --git a/shipment/models.py b/shipment/models.py index 0c7a630..cb87c0e 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -205,6 +205,13 @@ class Shipment(ModelBase): help_text='由 Geo 系统返回的坐标信息,结构由调用方决定,后端不作校验', ) + rejected_sales_item_ids = models.JSONField( + default=list, + blank=True, + verbose_name='驳回前销售品ID快照', + help_text='仅用于审计,记录出货单驳回前所绑定的销售品ID列表', + ) + class Meta: db_table = 'shipment' verbose_name = '出货单' diff --git a/shipment/services.py b/shipment/services.py index f245748..53e5434 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -625,7 +625,7 @@ def modify_status( 规则: - 草稿 -> 只能已发布 - 已发布 -> 已审核 / 已驳回 / 已取消 - - 已驳回 -> 已审核 / 已取消 + - 已驳回 -> 已取消 - 已审核 -> 已取消 - 已取消 -> 不可再变 - 重复设置同一状态保持幂等,直接返回 @@ -642,7 +642,9 @@ def modify_status( ShipmentStatus.CANCELLED, }, ShipmentStatus.REJECTED: { - ShipmentStatus.APPROVED, + # 驳回现在会解绑销售品并回退到待分配池。 + # 在没有重新绑定流程前,暂时关闭 REJECTED -> APPROVED,避免审核空出货单。 + # ShipmentStatus.APPROVED, ShipmentStatus.CANCELLED, }, ShipmentStatus.APPROVED: { @@ -660,6 +662,22 @@ def modify_status( if target_status == ShipmentStatus.APPROVED and approved_by is None: 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_modified_at = timezone.now() @@ -669,15 +687,7 @@ def modify_status( if target_status == ShipmentStatus.APPROVED: shipment.approved_by = approved_by - shipment.save( - update_fields=[ - "status", - "status_modified_at", - "cancelled_by", - "approved_by", - "updated_at", - ] - ) + shipment.save(update_fields=update_fields) return shipment