1
0
forked from erp-dev/erp

feat: notifier beta

This commit is contained in:
2026-04-13 12:30:33 +08:00
parent 646dbe7f18
commit 9512132bb9
50 changed files with 17830 additions and 2 deletions

308
mission/services.py Normal file
View File

@@ -0,0 +1,308 @@
import logging
from django.db import transaction
from django.utils import timezone
from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply
from mission.signals import (
mission_cancelled,
mission_completed,
mission_created,
mission_reopened,
mission_replied,
mission_reply_rejected,
)
logger = logging.getLogger(__name__)
DEFAULT_MISSION_CATEGORY_NAME = "通用"
def _send_signal_on_commit(signal, *, sender, **payload) -> None:
def _send():
try:
signal.send(sender=sender, **payload)
except Exception:
logger.exception("[mission.services] 触发 mission signal 失败(已忽略)")
transaction.on_commit(_send)
def _assert_employee_belongs_to_mission(employee, mission: Mission, role: str) -> None:
if employee is None:
raise ValueError(f"{role}不能为空")
if employee.merchant_id != mission.merchant_id:
raise ValueError(f"{role}不属于任务所属商户")
def _get_default_mission_category(*, merchant) -> MissionCategory:
category, _ = MissionCategory.objects.get_or_create(
merchant=merchant,
name=DEFAULT_MISSION_CATEGORY_NAME,
)
return category
def _assert_category_belongs_to_merchant(category: MissionCategory | None, *, merchant) -> None:
if category is None:
return
if category.merchant_id != merchant.id:
raise ValueError("任务分类不属于当前商户")
def _validate_content_object_merchant(*, content_type, content_id, merchant) -> None:
if content_type is None and content_id is None:
return
if content_type is None or content_id is None:
raise ValueError("content_type 与 content_id 必须同时提供或同时为空")
try:
content_object = content_type.get_object_for_this_type(pk=content_id)
except Exception as exc:
raise ValueError("关联业务对象不存在") from exc
content_object_merchant_id = getattr(content_object, "merchant_id", None)
if content_object_merchant_id is not None and content_object_merchant_id != merchant.id:
raise ValueError("关联业务对象不属于当前商户")
@transaction.atomic
def set_mission_participants(*, mission: Mission, participant_ids: list[int]) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
participant_ids = list(dict.fromkeys(participant_ids or []))
from basic_info.models import Employee
employees = list(Employee.objects.filter(id__in=participant_ids, merchant=mission.merchant))
if len(employees) != len(participant_ids):
raise ValueError("参与者不存在或不属于任务所属商户")
MissionParticipant.objects.filter(mission=mission).exclude(employee_id__in=participant_ids).delete()
existing_employee_ids = set(
MissionParticipant.objects.filter(mission=mission, employee_id__in=participant_ids)
.values_list("employee_id", flat=True)
)
MissionParticipant.objects.bulk_create(
[
MissionParticipant(merchant=mission.merchant, mission=mission, employee=employee)
for employee in employees
if employee.id not in existing_employee_ids
]
)
return mission
@transaction.atomic
def create_mission(
*,
creator,
description: str,
category: MissionCategory | None = None,
content_type=None,
content_id: int | None = None,
participant_ids: list[int] | None = None,
) -> 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)
mission = Mission.objects.create(
merchant=merchant,
creator=creator,
description=description,
category=category or _get_default_mission_category(merchant=merchant),
content_type=content_type,
content_id=content_id,
)
if participant_ids is not None:
set_mission_participants(mission=mission, participant_ids=participant_ids)
_send_signal_on_commit(
mission_created,
sender=Mission,
instance=mission,
created_by=creator,
)
return mission
@transaction.atomic
def update_mission(
*,
mission: Mission,
updated_by,
description: str | None = None,
category: MissionCategory | None = None,
content_type=None,
content_id: int | None = None,
update_content_object: bool = False,
participant_ids: list[int] | None = None,
) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(updated_by, mission, "更新任务的员工")
update_fields = []
if description is not None:
mission.description = description
update_fields.append("description")
if category is not None:
_assert_category_belongs_to_merchant(category, merchant=mission.merchant)
mission.category = category
update_fields.append("category")
if update_content_object:
_validate_content_object_merchant(
content_type=content_type,
content_id=content_id,
merchant=mission.merchant,
)
mission.content_type = content_type
mission.content_id = content_id
update_fields.extend(["content_type", "content_id"])
if update_fields:
mission.save(update_fields=[*update_fields, "updated_at"])
if participant_ids is not None:
set_mission_participants(mission=mission, participant_ids=participant_ids)
return mission
@transaction.atomic
def create_mission_reply(
*,
mission: Mission,
responder,
content: str,
ends_task: bool = False,
) -> MissionReply:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(responder, mission, "回应者")
if not mission.can_reply:
raise ValueError("任务当前不允许继续回应")
reply = MissionReply.objects.create(
merchant=mission.merchant,
mission=mission,
responder=responder,
content=content,
ends_task=ends_task,
)
if ends_task and not mission.is_completed:
mission.is_completed = True
mission.save(update_fields=["is_completed", "updated_at"])
_send_signal_on_commit(
mission_completed,
sender=Mission,
instance=mission,
completed_by=responder,
reply=reply,
)
_send_signal_on_commit(
mission_replied,
sender=MissionReply,
instance=reply,
mission=mission,
responder=responder,
)
return reply
@transaction.atomic
def reopen_mission(*, mission: Mission, reopened_by) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(reopened_by, mission, "重新打开任务的员工")
ending_replies = mission.replies.select_for_update().filter(ends_task=True, is_rejected=False)
if mission.is_cancelled:
raise ValueError("已取消任务不能重新打开")
if not mission.is_completed:
raise ValueError("未完成任务不能重新打开")
if not ending_replies.exists():
raise ValueError("任务没有可撤销的结束回应")
rejected_reply_ids = list(ending_replies.values_list("id", flat=True))
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"])
for reply in MissionReply.objects.filter(id__in=rejected_reply_ids):
_send_signal_on_commit(
mission_reply_rejected,
sender=MissionReply,
instance=reply,
mission=mission,
rejected_by=reopened_by,
reason="reopen",
)
_send_signal_on_commit(
mission_reopened,
sender=Mission,
instance=mission,
reopened_by=reopened_by,
rejected_reply_ids=rejected_reply_ids,
)
return mission
@transaction.atomic
def reject_reply(*, reply: MissionReply, rejected_by) -> MissionReply:
reply = MissionReply.objects.select_for_update().select_related("mission").get(pk=reply.pk)
mission = Mission.objects.select_for_update().get(pk=reply.mission_id)
_assert_employee_belongs_to_mission(rejected_by, mission, "撤销回应的员工")
if mission.is_cancelled:
raise ValueError("已取消任务不能撤销回应")
if reply.is_rejected:
raise ValueError("回应已被撤销")
was_ending_reply = reply.ends_task
now = timezone.now()
reply.ends_task = False
reply.is_rejected = True
reply.rejected_by = rejected_by
reply.rejected_at = now
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()
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"])
_send_signal_on_commit(
mission_reply_rejected,
sender=MissionReply,
instance=reply,
mission=mission,
rejected_by=rejected_by,
reason="reject_reply",
)
return reply
@transaction.atomic
def cancel_mission(*, mission: Mission, cancelled_by) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(cancelled_by, mission, "取消任务的员工")
if mission.is_cancelled:
raise ValueError("任务已取消")
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"])
_send_signal_on_commit(
mission_cancelled,
sender=Mission,
instance=mission,
cancelled_by=cancelled_by,
)
return mission
@transaction.atomic
def set_mission_urgent(*, mission: Mission, updated_by, is_urgent: bool) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(updated_by, mission, "更新任务紧急状态的员工")
mission.is_urgent = is_urgent
mission.save(update_fields=["is_urgent", "updated_at"])
return mission