1
0
forked from erp-dev/erp
Files
erpnew/api_v2/views/mission.py
2026-07-01 11:51:13 +08:00

773 lines
30 KiB
Python

from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db import IntegrityError
from django.db.models import Exists, OuterRef, Prefetch, Q
from django.db.models import ProtectedError
from django.shortcuts import get_object_or_404
from rest_framework import permissions, serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from mission import models as mission_models
from mission import services as mission_services
STATUS_FIELDS = {"is_urgent", "is_completed", "is_cancelled", "cancelled_by", "cancelled_at", "rejected_by", "rejected_at"}
MISSION_BY_PRINTING_ORDER_CACHE_TIMEOUT = 180
def _get_employee(request):
employee = getattr(request.user, "employee", None)
if employee is None:
raise serializers.ValidationError("当前用户未关联员工")
return employee
def _employee_payload(employee):
if employee is None:
return None
return {
"id": employee.id,
"name": employee.name,
"merchant_id": employee.merchant_id,
}
class MissionWriteSerializer(serializers.Serializer):
description = serializers.CharField(required=False, allow_blank=False)
category = serializers.IntegerField(required=False, min_value=1)
content_type = serializers.IntegerField(required=False, allow_null=True, min_value=1)
content_id = serializers.IntegerField(required=False, allow_null=True, min_value=1)
extra = serializers.JSONField(required=False, allow_null=True)
notify_if_unreplied = serializers.BooleanField(required=False)
unreplied_notify_interval_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=1)
unreplied_notify_max_count = serializers.IntegerField(required=False, min_value=1)
participant_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
required=False,
allow_empty=True,
)
def __init__(self, *args, **kwargs):
self.is_create = kwargs.pop("is_create", False)
super().__init__(*args, **kwargs)
if self.is_create:
self.fields["description"].required = True
def validate_category(self, value):
employee = self.context.get("employee")
if employee is None:
return value
try:
return mission_models.MissionCategory.objects.get(id=value, merchant=employee.merchant)
except mission_models.MissionCategory.DoesNotExist as exc:
raise serializers.ValidationError("任务分类不存在") from exc
def validate(self, attrs):
forbidden_fields = STATUS_FIELDS.intersection(self.initial_data.keys())
if forbidden_fields:
raise serializers.ValidationError({field: "状态字段不能通过普通 CRUD 接口修改" for field in sorted(forbidden_fields)})
content_type_provided = "content_type" in self.initial_data
content_id_provided = "content_id" in self.initial_data
if content_type_provided != content_id_provided:
raise serializers.ValidationError("content_type 与 content_id 必须同时提供或同时省略")
content_type_id = attrs.get("content_type")
if content_type_id is not None:
try:
attrs["content_type"] = ContentType.objects.get(id=content_type_id)
except ContentType.DoesNotExist as exc:
raise serializers.ValidationError({"content_type": "ContentType 不存在"}) from exc
instance = self.context.get("instance")
final_notify_if_unreplied = attrs.get(
"notify_if_unreplied",
getattr(instance, "notify_if_unreplied", False),
)
final_interval = attrs.get(
"unreplied_notify_interval_minutes",
getattr(instance, "unreplied_notify_interval_minutes", None),
)
if final_notify_if_unreplied and final_interval is None:
raise serializers.ValidationError(
{"unreplied_notify_interval_minutes": "开启未回复提醒时必须设置提醒间隔"}
)
return attrs
class MissionReplyCreateSerializer(serializers.Serializer):
content = serializers.CharField(allow_blank=False)
ends_task = serializers.BooleanField(required=False, default=False)
extra = serializers.JSONField(required=False, allow_null=True)
class MissionUrgentSerializer(serializers.Serializer):
is_urgent = serializers.BooleanField()
class MissionCategoryWriteSerializer(serializers.Serializer):
name = serializers.CharField(allow_blank=False, max_length=50)
speech_enabled = serializers.BooleanField(required=False)
payload_processor = serializers.ChoiceField(
choices=mission_models.MissionPayloadProcessorEnum.choices,
required=False,
allow_blank=True,
)
def validate_name(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError("分类名称不能为空")
employee = self.context["employee"]
instance = self.context.get("instance")
queryset = mission_models.MissionCategory.objects.filter(
merchant=employee.merchant,
name=value,
)
if instance is not None:
queryset = queryset.exclude(id=instance.id)
if queryset.exists():
raise serializers.ValidationError("分类名称已存在")
return value
class MissionSerializer(serializers.ModelSerializer):
creator = serializers.SerializerMethodField()
cancelled_by = serializers.SerializerMethodField()
participants = serializers.SerializerMethodField()
content_type_label = serializers.SerializerMethodField()
category = serializers.IntegerField(source="category_id", read_only=True)
category_name = serializers.CharField(source="category.name", read_only=True)
has_ending_reply = serializers.SerializerMethodField()
can_reply = serializers.SerializerMethodField()
class Meta:
model = mission_models.Mission
fields = [
"id",
"merchant",
"description",
"category",
"category_name",
"is_urgent",
"is_completed",
"is_cancelled",
"notify_if_unreplied",
"unreplied_notify_interval_minutes",
"unreplied_notify_max_count",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"cancelled_at",
"creator",
"cancelled_by",
"participants",
"content_type",
"content_type_label",
"content_id",
"extra",
"has_ending_reply",
"can_reply",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_creator(self, obj):
return _employee_payload(obj.creator)
def get_cancelled_by(self, obj):
return _employee_payload(obj.cancelled_by)
def get_participants(self, obj):
prefetched_participants = getattr(obj, "_prefetched_objects_cache", {}).get("participants")
if prefetched_participants is not None:
return [_employee_payload(participant.employee) for participant in prefetched_participants]
return [
_employee_payload(participant.employee)
for participant in obj.participants.select_related("employee", "employee__merchant")
]
def get_content_type_label(self, obj):
if obj.content_type_id is None:
return None
return f"{obj.content_type.app_label}.{obj.content_type.model}"
def get_has_ending_reply(self, obj):
annotated_value = getattr(obj, "has_ending_reply_value", None)
if annotated_value is not None:
return annotated_value
return obj.has_ending_reply
def get_can_reply(self, obj):
return not obj.is_cancelled and not self.get_has_ending_reply(obj)
class MissionLiteSerializer(serializers.ModelSerializer):
creator = serializers.SerializerMethodField()
cancelled_by = serializers.SerializerMethodField()
content_type_label = serializers.SerializerMethodField()
category = serializers.IntegerField(source="category_id", read_only=True)
category_name = serializers.CharField(source="category.name", read_only=True)
class Meta:
model = mission_models.Mission
fields = [
"id",
"merchant",
"description",
"category",
"category_name",
"is_urgent",
"is_completed",
"is_cancelled",
"notify_if_unreplied",
"unreplied_notify_interval_minutes",
"unreplied_notify_max_count",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"cancelled_at",
"creator",
"cancelled_by",
"content_type",
"content_type_label",
"content_id",
"extra",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_creator(self, obj):
return _employee_payload(obj.creator)
def get_cancelled_by(self, obj):
return _employee_payload(obj.cancelled_by)
def get_content_type_label(self, obj):
if obj.content_type_id is None:
return None
return f"{obj.content_type.app_label}.{obj.content_type.model}"
class MissionCategorySerializer(serializers.ModelSerializer):
class Meta:
model = mission_models.MissionCategory
fields = [
"id",
"merchant",
"name",
"payload_processor",
"speech_enabled",
"created_at",
"updated_at",
]
read_only_fields = fields
class MissionReplySerializer(serializers.ModelSerializer):
responder = serializers.SerializerMethodField()
rejected_by = serializers.SerializerMethodField()
class Meta:
model = mission_models.MissionReply
fields = [
"id",
"mission",
"merchant",
"responder",
"content",
"extra",
"replied_at",
"ends_task",
"is_rejected",
"rejected_by",
"rejected_at",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_responder(self, obj):
return _employee_payload(obj.responder)
def get_rejected_by(self, obj):
return _employee_payload(obj.rejected_by)
class MissionWithRepliesSerializer(MissionSerializer):
replies = serializers.SerializerMethodField()
class Meta(MissionSerializer.Meta):
fields = [*MissionSerializer.Meta.fields, "replies"]
read_only_fields = fields
def get_replies(self, obj):
replies = getattr(obj, "prefetched_replies", None)
if replies is None:
replies = (
obj.replies.select_related(
"responder",
"responder__merchant",
"rejected_by",
"rejected_by__merchant",
)
.order_by("replied_at", "id")
)
return MissionReplySerializer(replies, many=True).data
def _parse_int_list_query_param(request, param_name: str) -> list[int] | None:
raw_values = request.query_params.getlist(param_name)
if not raw_values:
single_value = request.query_params.get(param_name)
if single_value is None:
return None
raw_values = [single_value]
tokens = []
for raw_value in raw_values:
if raw_value is None:
continue
parts = [part.strip() for part in str(raw_value).split(",")]
tokens.extend(part for part in parts if part)
if not tokens:
return None
try:
values = [int(token) for token in tokens]
except (TypeError, ValueError) as exc:
raise serializers.ValidationError({param_name: "必须是整数 ID 列表"}) from exc
if any(value <= 0 for value in values):
raise serializers.ValidationError({param_name: "必须是正整数 ID 列表"})
return list(dict.fromkeys(values))
def _parse_bool_query_param(request, param_name: str, default: bool) -> bool:
raw_value = request.query_params.get(param_name)
if raw_value is None:
return default
value = str(raw_value).strip().lower()
if value in {"1", "true", "yes", "y", "on"}:
return True
if value in {"0", "false", "no", "n", "off"}:
return False
raise serializers.ValidationError({param_name: "必须是布尔值"})
def _mission_queryset_for_employee(employee, *, include_details: bool = True):
has_ending_reply_subquery = mission_models.MissionReply.objects.filter(
mission_id=OuterRef("pk"),
ends_task=True,
is_rejected=False,
)
queryset = (
mission_models.Mission.objects.filter(merchant=employee.merchant)
.annotate(has_ending_reply_value=Exists(has_ending_reply_subquery))
.select_related(
"merchant",
"category",
"creator",
"creator__merchant",
"cancelled_by",
"cancelled_by__merchant",
"content_type",
)
.order_by("-created_at", "-id")
)
if include_details:
queryset = queryset.prefetch_related("participants__employee", "participants__employee__merchant")
return queryset
def _mission_by_printing_order_cache_key(*, merchant_id: int, printing_order_id: int, category_ids: list[int] | None,
include_details: bool) -> str:
category_key = "all" if category_ids is None else ",".join(str(category_id) for category_id in category_ids)
detail_key = "detail" if include_details else "lite"
return f"mission:by-printing-order:{merchant_id}:{printing_order_id}:{category_key}:{detail_key}"
def _mission_category_queryset_for_employee(employee):
return mission_models.MissionCategory.objects.filter(merchant=employee.merchant).order_by("id")
class MissionCategoryListCreateView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
employee = _get_employee(request)
queryset = _mission_category_queryset_for_employee(employee)
return Response(MissionCategorySerializer(queryset, many=True).data)
def post(self, request):
employee = _get_employee(request)
serializer = MissionCategoryWriteSerializer(data=request.data, context={"employee": employee})
serializer.is_valid(raise_exception=True)
try:
category = mission_models.MissionCategory.objects.create(
merchant=employee.merchant,
name=serializer.validated_data["name"],
payload_processor=serializer.validated_data.get("payload_processor", ""),
speech_enabled=serializer.validated_data.get("speech_enabled", False),
)
except IntegrityError:
return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionCategorySerializer(category).data, status=status.HTTP_201_CREATED)
class MissionCategoryDetailView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get_object(self, request, category_id):
employee = _get_employee(request)
return get_object_or_404(_mission_category_queryset_for_employee(employee), id=category_id)
def get(self, request, category_id):
category = self.get_object(request, category_id)
return Response(MissionCategorySerializer(category).data)
def patch(self, request, category_id):
employee = _get_employee(request)
category = self.get_object(request, category_id)
serializer = MissionCategoryWriteSerializer(
data=request.data,
partial=True,
context={"employee": employee, "instance": category},
)
serializer.is_valid(raise_exception=True)
update_fields = []
if "name" in serializer.validated_data:
category.name = serializer.validated_data["name"]
update_fields.append("name")
if "payload_processor" in serializer.validated_data:
category.payload_processor = serializer.validated_data["payload_processor"]
update_fields.append("payload_processor")
if "speech_enabled" in serializer.validated_data:
category.speech_enabled = serializer.validated_data["speech_enabled"]
update_fields.append("speech_enabled")
if update_fields:
try:
category.save(update_fields=[*update_fields, "updated_at"])
except IntegrityError:
return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionCategorySerializer(category).data)
def delete(self, request, category_id):
category = self.get_object(request, category_id)
try:
category.delete()
except ProtectedError:
return Response({"detail": "任务分类已被使用,不能删除"}, status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)
class MissionListCreateView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
employee = _get_employee(request)
queryset = _mission_queryset_for_employee(employee)
if request.query_params.get("category"):
queryset = queryset.filter(category_id=request.query_params["category"])
for field in ["is_urgent", "is_completed", "is_cancelled"]:
value = request.query_params.get(field)
if value is not None:
queryset = queryset.filter(**{field: value.lower() in ["1", "true", "yes"]})
if request.query_params.get("content_type"):
queryset = queryset.filter(content_type_id=request.query_params["content_type"])
if request.query_params.get("content_id"):
queryset = queryset.filter(content_id=request.query_params["content_id"])
return Response(MissionSerializer(queryset, many=True).data)
def post(self, request):
employee = _get_employee(request)
serializer = MissionWriteSerializer(data=request.data, is_create=True, context={"employee": employee})
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
mission = mission_services.create_mission(
creator=employee,
description=data["description"],
category=data.get("category"),
content_type=data.get("content_type"),
content_id=data.get("content_id"),
extra=data.get("extra"),
participant_ids=data.get("participant_ids"),
notify_if_unreplied=data.get("notify_if_unreplied", False),
unreplied_notify_interval_minutes=data.get("unreplied_notify_interval_minutes"),
unreplied_notify_max_count=data.get("unreplied_notify_max_count", 5),
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data, status=status.HTTP_201_CREATED)
class MissionByPrintingOrderView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request, printing_order_id):
employee = _get_employee(request)
from printing.models import PrintingOrder
printing_order = get_object_or_404(
PrintingOrder.objects.filter(merchant=employee.merchant),
id=printing_order_id,
)
category_ids = _parse_int_list_query_param(request, "category_ids")
include_details = _parse_bool_query_param(request, "include_details", default=True)
cache_key = _mission_by_printing_order_cache_key(
merchant_id=employee.merchant_id,
printing_order_id=printing_order.id,
category_ids=category_ids,
include_details=include_details,
)
cached_data = cache.get(cache_key)
if cached_data is not None:
return Response(cached_data)
printing_order_content_type = ContentType.objects.get_for_model(PrintingOrder)
queryset = _mission_queryset_for_employee(employee, include_details=include_details).filter(
content_type=printing_order_content_type,
content_id=printing_order.id,
)
if category_ids is not None:
queryset = queryset.filter(category_id__in=category_ids)
if include_details:
replies_queryset = mission_models.MissionReply.objects.select_related(
"responder",
"responder__merchant",
"rejected_by",
"rejected_by__merchant",
).order_by("replied_at", "id")
queryset = queryset.prefetch_related(
Prefetch("replies", queryset=replies_queryset, to_attr="prefetched_replies")
)
serializer_class = MissionWithRepliesSerializer if include_details else MissionLiteSerializer
data = serializer_class(queryset, many=True).data
cache.set(cache_key, data, timeout=MISSION_BY_PRINTING_ORDER_CACHE_TIMEOUT)
return Response(data)
class MissionDetailView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get_object(self, request, mission_id):
employee = _get_employee(request)
return get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
def get(self, request, mission_id):
mission = self.get_object(request, mission_id)
return Response(MissionSerializer(mission).data)
def patch(self, request, mission_id):
employee = _get_employee(request)
mission = self.get_object(request, mission_id)
serializer = MissionWriteSerializer(
data=request.data,
partial=True,
context={"employee": employee, "instance": mission},
)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
mission = mission_services.update_mission(
mission=mission,
updated_by=employee,
description=data.get("description"),
category=data.get("category"),
content_type=data.get("content_type"),
content_id=data.get("content_id"),
extra=(data["extra"] if "extra" in data else mission_services.UNSET),
update_content_object=("content_type" in request.data or "content_id" in request.data),
participant_ids=data.get("participant_ids"),
notify_if_unreplied=(
data["notify_if_unreplied"]
if "notify_if_unreplied" in data
else mission_services.UNSET
),
unreplied_notify_interval_minutes=(
data["unreplied_notify_interval_minutes"]
if "unreplied_notify_interval_minutes" in data
else mission_services.UNSET
),
unreplied_notify_max_count=(
data["unreplied_notify_max_count"]
if "unreplied_notify_max_count" in data
else mission_services.UNSET
),
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data)
def delete(self, request, mission_id):
return Response({"detail": "Mission 删除接口未提供,请使用 cancel 接口取消任务"}, status=status.HTTP_405_METHOD_NOT_ALLOWED)
class MissionReplyListCreateView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request, mission_id):
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
queryset = (
mission_models.MissionReply.objects.filter(mission=mission)
.select_related("responder", "responder__merchant", "rejected_by", "rejected_by__merchant")
.order_by("replied_at", "id")
)
for field in ["ends_task", "is_rejected"]:
value = request.query_params.get(field)
if value is not None:
queryset = queryset.filter(**{field: value.lower() in ["1", "true", "yes"]})
return Response(MissionReplySerializer(queryset, many=True).data)
def post(self, request, mission_id):
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
serializer = MissionReplyCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
reply = mission_services.create_mission_reply(
mission=mission,
responder=employee,
content=serializer.validated_data["content"],
ends_task=serializer.validated_data.get("ends_task", False),
extra=serializer.validated_data.get("extra"),
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionReplySerializer(reply).data, status=status.HTTP_201_CREATED)
class MissionReopenView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, mission_id):
if not request.user.has_perm("mission.reopen_mission"):
return Response({"detail": "缺少重新打开任务权限"}, status=status.HTTP_403_FORBIDDEN)
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
try:
mission = mission_services.reopen_mission(mission=mission, reopened_by=employee)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data)
class MissionCancelView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, mission_id):
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
try:
mission = mission_services.cancel_mission(mission=mission, cancelled_by=employee)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data)
class MissionReplyRejectView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, reply_id):
if not request.user.has_perm("mission.reject_mission_reply"):
return Response({"detail": "缺少撤销任务回应权限"}, status=status.HTTP_403_FORBIDDEN)
employee = _get_employee(request)
reply = get_object_or_404(
mission_models.MissionReply.objects.select_related("mission", "responder", "rejected_by").filter(
merchant=employee.merchant,
),
id=reply_id,
)
try:
reply = mission_services.reject_reply(reply=reply, rejected_by=employee)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionReplySerializer(reply).data)
class MissionSetUrgentView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, mission_id):
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
serializer = MissionUrgentSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
mission = mission_services.set_mission_urgent(
mission=mission,
updated_by=employee,
is_urgent=serializer.validated_data["is_urgent"],
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data)
class ContentTypeListView(APIView):
permission_classes = [permissions.IsAuthenticated]
# Models the frontend is allowed to use as mission content_type targets.
_ALLOWED_CONTENT_TYPES = [
("printing", "plateorder"),
("printing", "printingorder"),
("printing", "printingjob"),
("business", "purchaseorder"),
("business", "presalesorder"),
("business", "salesorder"),
("business", "prepurchaseorder"),
("business", "purchasereturnorder"),
("business", "salesreturnorder"),
("business", "paymentorder"),
("business", "receiptorder"),
("stock", "transferorder"),
("shipment", "shipment"),
("stateflow", "process"),
]
# Human-readable labels keyed by (app_label, model).
_LABELS = {
("printing", "plateorder"): "开版单",
("printing", "printingorder"): "印刷单",
("printing", "printingjob"): "印刷任务",
("business", "purchaseorder"): "采购单",
("business", "presalesorder"): "预销售单",
("business", "salesorder"): "销售单",
("business", "prepurchaseorder"): "预采购单",
("business", "purchasereturnorder"): "采购退货单",
("business", "salesreturnorder"): "销售退货单",
("business", "paymentorder"): "付款单",
("business", "receiptorder"): "收款单",
("stock", "transferorder"): "调拨单",
("shipment", "shipment"): "发货单",
("stateflow", "process"): "工艺流程实例",
}
def get(self, request):
filters = Q()
for app_label, model in self._ALLOWED_CONTENT_TYPES:
filters |= Q(app_label=app_label, model=model)
cts = ContentType.objects.filter(filters).order_by("app_label", "model")
data = [
{
"id": ct.id,
"app_label": ct.app_label,
"model": ct.model,
"label": f"{ct.app_label}.{ct.model}",
"name": self._LABELS.get((ct.app_label, ct.model), ct.model),
}
for ct in cts
]
return Response(data)