1
0
forked from erp-dev/erp
Files
erpnew/mission/services.py
2026-05-01 22:43:22 +08:00

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)