forked from erp-dev/erp
feat: mission replied calling task
This commit is contained in:
@@ -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", "任务已取消"
|
||||
|
||||
@@ -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
|
||||
|
||||
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 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()
|
||||
|
||||
Reference in New Issue
Block a user