import logging from datetime import timedelta 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 = "通用" UNSET = object() 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("关联业务对象不属于当前商户") 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) 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, notify_if_unreplied: bool = False, unreplied_notify_interval_minutes: int | None = None, unreplied_notify_max_count: int = 5, extra=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) _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, creator=creator, description=description, category=category or _get_default_mission_category(merchant=merchant), content_type=content_type, content_id=content_id, extra=extra, 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) _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, notify_if_unreplied=UNSET, unreplied_notify_interval_minutes=UNSET, unreplied_notify_max_count=UNSET, extra=UNSET, ) -> 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 extra is not UNSET: mission.extra = extra update_fields.append("extra") 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=[*dict.fromkeys(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, extra=None, ) -> 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, extra=extra, ) if ends_task and not mission.is_completed: mission.is_completed = True _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, instance=mission, completed_by=responder, reply=reply, ) else: _clear_unreplied_notification_state_if_active(mission=mission) _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 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, 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() 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 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, 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() _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, 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 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)