forked from erp-dev/erp
467 lines
16 KiB
Python
467 lines
16 KiB
Python
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)
|