forked from erp-dev/erp
feat: prod clean
This commit is contained in:
@@ -20,6 +20,7 @@ from .printing import (
|
||||
from .stateflow import BusinessObjectCloneView
|
||||
from .mission import (
|
||||
ContentTypeListView,
|
||||
MissionByPrintingOrderView,
|
||||
MissionCancelView,
|
||||
MissionCategoryDetailView,
|
||||
MissionCategoryListCreateView,
|
||||
@@ -31,6 +32,7 @@ from .mission import (
|
||||
MissionSetUrgentView,
|
||||
)
|
||||
from .ai import AgentUnshippedShipmentListView, AgentTransportVehicleListView, AgentTransportVehicleDetailView
|
||||
from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView
|
||||
|
||||
__all__ = [
|
||||
'HealthCheckView',
|
||||
@@ -58,7 +60,10 @@ __all__ = [
|
||||
'MissionReplyRejectView',
|
||||
'MissionSetUrgentView',
|
||||
'ContentTypeListView',
|
||||
'MissionByPrintingOrderView',
|
||||
'AgentUnshippedShipmentListView',
|
||||
'AgentTransportVehicleListView',
|
||||
'AgentTransportVehicleDetailView',
|
||||
'ShipmentDeliveryPhotoListCreateView',
|
||||
'ShipmentDeliveryPhotoDetailView',
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Q
|
||||
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
|
||||
@@ -12,6 +13,7 @@ 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):
|
||||
@@ -36,6 +38,7 @@ class MissionWriteSerializer(serializers.Serializer):
|
||||
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)
|
||||
@@ -96,6 +99,7 @@ class MissionWriteSerializer(serializers.Serializer):
|
||||
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):
|
||||
@@ -129,8 +133,8 @@ class MissionSerializer(serializers.ModelSerializer):
|
||||
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.BooleanField(read_only=True)
|
||||
can_reply = serializers.BooleanField(read_only=True)
|
||||
has_ending_reply = serializers.SerializerMethodField()
|
||||
can_reply = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = mission_models.Mission
|
||||
@@ -155,6 +159,7 @@ class MissionSerializer(serializers.ModelSerializer):
|
||||
"content_type",
|
||||
"content_type_label",
|
||||
"content_id",
|
||||
"extra",
|
||||
"has_ending_reply",
|
||||
"can_reply",
|
||||
"created_at",
|
||||
@@ -169,6 +174,9 @@ class MissionSerializer(serializers.ModelSerializer):
|
||||
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")
|
||||
@@ -179,6 +187,62 @@ class MissionSerializer(serializers.ModelSerializer):
|
||||
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:
|
||||
@@ -205,6 +269,7 @@ class MissionReplySerializer(serializers.ModelSerializer):
|
||||
"merchant",
|
||||
"responder",
|
||||
"content",
|
||||
"extra",
|
||||
"replied_at",
|
||||
"ends_task",
|
||||
"is_rejected",
|
||||
@@ -222,9 +287,78 @@ class MissionReplySerializer(serializers.ModelSerializer):
|
||||
return _employee_payload(obj.rejected_by)
|
||||
|
||||
|
||||
def _mission_queryset_for_employee(employee):
|
||||
return (
|
||||
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",
|
||||
@@ -234,9 +368,18 @@ def _mission_queryset_for_employee(employee):
|
||||
"cancelled_by__merchant",
|
||||
"content_type",
|
||||
)
|
||||
.prefetch_related("participants__employee", "participants__employee__merchant")
|
||||
.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):
|
||||
@@ -334,6 +477,7 @@ class MissionListCreateView(APIView):
|
||||
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"),
|
||||
@@ -344,6 +488,56 @@ class MissionListCreateView(APIView):
|
||||
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]
|
||||
|
||||
@@ -373,6 +567,7 @@ class MissionDetailView(APIView):
|
||||
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=(
|
||||
@@ -427,6 +622,7 @@ class MissionReplyListCreateView(APIView):
|
||||
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)
|
||||
|
||||
144
api_v2/views/shipment_delivery_photo.py
Normal file
144
api_v2/views/shipment_delivery_photo.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from rest_framework import permissions, serializers, status
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from shipment import models as shipment_models
|
||||
|
||||
|
||||
def _get_employee(request):
|
||||
employee = getattr(request.user, 'employee', None)
|
||||
if employee is None:
|
||||
raise serializers.ValidationError('当前用户未关联员工')
|
||||
return employee
|
||||
|
||||
|
||||
class ShipmentDeliveryPhotoWriteSerializer(serializers.ModelSerializer):
|
||||
shipment = serializers.PrimaryKeyRelatedField(queryset=shipment_models.Shipment.objects.all())
|
||||
delivery = serializers.PrimaryKeyRelatedField(queryset=shipment_models.ShipmentDelivery.objects.all())
|
||||
|
||||
class Meta:
|
||||
model = shipment_models.ShipmentDeliveryPhoto
|
||||
fields = ['shipment', 'delivery', 'photo', 'remark']
|
||||
|
||||
def validate(self, attrs):
|
||||
employee = self.context['employee']
|
||||
instance = self.instance
|
||||
shipment = attrs.get('shipment', getattr(instance, 'shipment', None))
|
||||
delivery = attrs.get('delivery', getattr(instance, 'delivery', None))
|
||||
|
||||
if shipment is None:
|
||||
raise serializers.ValidationError({'shipment': '出货单不能为空'})
|
||||
if delivery is None:
|
||||
raise serializers.ValidationError({'delivery': '送货单不能为空'})
|
||||
if shipment.merchant_id != employee.merchant_id:
|
||||
raise serializers.ValidationError({'shipment': '出货单不属于当前商户'})
|
||||
if delivery.merchant_id != employee.merchant_id:
|
||||
raise serializers.ValidationError({'delivery': '送货单不属于当前商户'})
|
||||
if shipment.delivery_id != delivery.id:
|
||||
raise serializers.ValidationError({'delivery': '送货单与出货单当前绑定关系不一致'})
|
||||
return attrs
|
||||
|
||||
|
||||
class ShipmentDeliveryPhotoReadSerializer(serializers.ModelSerializer):
|
||||
created_by = serializers.IntegerField(source='created_by_id', read_only=True, allow_null=True)
|
||||
shipment_id = serializers.IntegerField(read_only=True)
|
||||
delivery_id = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = shipment_models.ShipmentDeliveryPhoto
|
||||
fields = [
|
||||
'id',
|
||||
'shipment_id',
|
||||
'delivery_id',
|
||||
'photo',
|
||||
'remark',
|
||||
'created_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ShipmentDeliveryPhotoListCreateView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def _queryset(self, employee):
|
||||
return shipment_models.ShipmentDeliveryPhoto.objects.select_related(
|
||||
'shipment', 'delivery', 'created_by'
|
||||
).filter(
|
||||
shipment__merchant=employee.merchant,
|
||||
delivery__merchant=employee.merchant,
|
||||
)
|
||||
|
||||
def get(self, request):
|
||||
employee = _get_employee(request)
|
||||
queryset = self._queryset(employee)
|
||||
|
||||
shipment_id = request.query_params.get('shipment_id')
|
||||
delivery_id = request.query_params.get('delivery_id')
|
||||
if shipment_id:
|
||||
queryset = queryset.filter(shipment_id=shipment_id)
|
||||
if delivery_id:
|
||||
queryset = queryset.filter(delivery_id=delivery_id)
|
||||
|
||||
return Response(ShipmentDeliveryPhotoReadSerializer(queryset, many=True, context={'request': request}).data)
|
||||
|
||||
def post(self, request):
|
||||
employee = _get_employee(request)
|
||||
serializer = ShipmentDeliveryPhotoWriteSerializer(data=request.data, context={'employee': employee})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
photo = serializer.save(created_by=request.user)
|
||||
return Response(
|
||||
ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class ShipmentDeliveryPhotoDetailView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def get_object(self, request, photo_id):
|
||||
employee = _get_employee(request)
|
||||
return shipment_models.ShipmentDeliveryPhoto.objects.select_related(
|
||||
'shipment', 'delivery', 'created_by'
|
||||
).filter(
|
||||
shipment__merchant=employee.merchant,
|
||||
delivery__merchant=employee.merchant,
|
||||
id=photo_id,
|
||||
).first()
|
||||
|
||||
def get(self, request, photo_id):
|
||||
photo = self.get_object(request, photo_id)
|
||||
if photo is None:
|
||||
return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data)
|
||||
|
||||
def patch(self, request, photo_id):
|
||||
photo = self.get_object(request, photo_id)
|
||||
if photo is None:
|
||||
return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND)
|
||||
employee = _get_employee(request)
|
||||
serializer = ShipmentDeliveryPhotoWriteSerializer(photo, data=request.data, partial=True, context={'employee': employee})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
photo = serializer.save()
|
||||
return Response(ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data)
|
||||
|
||||
def put(self, request, photo_id):
|
||||
photo = self.get_object(request, photo_id)
|
||||
if photo is None:
|
||||
return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND)
|
||||
employee = _get_employee(request)
|
||||
serializer = ShipmentDeliveryPhotoWriteSerializer(photo, data=request.data, context={'employee': employee})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
photo = serializer.save()
|
||||
return Response(ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data)
|
||||
|
||||
def delete(self, request, photo_id):
|
||||
photo = self.get_object(request, photo_id)
|
||||
if photo is None:
|
||||
return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND)
|
||||
photo.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
Reference in New Issue
Block a user