""" Shipment API ViewSet """ from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.generics import GenericAPIView from rest_framework.mixins import ListModelMixin, RetrieveModelMixin from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from django.utils.dateparse import parse_date, parse_datetime from flower.viewsets import LimitedLimitOffsetPagination from shipment.models import SalesItem, Shipment, ShipmentDelivery, ShipmentDeliveryStatus, ShipmentStatus from .serializers import ( SalesItemDetailSerializer, SalesItemRebuildSerializer, SalesItemSerializer, SalesItemUpdateSerializer, ShipmentStatusUpdateSerializer, ShipmentDeliveryBindShipmentsSerializer, ShipmentDeliveryByPrintingOrderSerializer, ShipmentDeliveryCreateSerializer, ShipmentDeliverySerializer, ShipmentDeliveryStatusUpdateSerializer, ShipmentDeliveryUpdateSerializer, ShipmentPrintingJobSummarySerializer, ShipmentSerializer, ShipmentCreateNormalSerializer, ShipmentCreateExternalSerializer, ShipmentSalesItemCustomerSerializer, ShipmentUpdateSerializer, SHIPMENT_STAGE_CANCELLED, SHIPMENT_STAGE_DELIVERABLE, SHIPMENT_STAGE_DELIVERED, SHIPMENT_STAGE_MISSING_ADDRESS, SHIPMENT_STAGE_SCHEDULED, ) SHIPMENT_STAGE_CHOICES = { SHIPMENT_STAGE_MISSING_ADDRESS, SHIPMENT_STAGE_DELIVERABLE, SHIPMENT_STAGE_SCHEDULED, SHIPMENT_STAGE_DELIVERED, SHIPMENT_STAGE_CANCELLED, } def _build_sales_item_serializer_context(items): customer_ids = {item.customer_id for item in items if item.customer_id} printing_job_ids = {item.printing_job_id for item in items if item.printing_job_id} customer_name_map = {} if customer_ids: from basic_info.models import Customer customer_name_map = dict( Customer.objects.filter(id__in=customer_ids).values_list("id", "name") ) printing_order_map = {} external_order_id_map = {} product_image_map = {} if printing_job_ids: from printing.models import PrintingJob printing_jobs = list( PrintingJob.objects.filter(id__in=printing_job_ids) .select_related("printing_order", "product") ) printing_order_map = { job.id: job.printing_order_id for job in printing_jobs } external_order_id_map = { job.id: getattr(job.printing_order, "external_order_id", None) for job in printing_jobs } for job in printing_jobs: if not getattr(job, "product", None): product_image_map[job.id] = None continue primary_url = job.product.get_primary_image_url() if primary_url: product_image_map[job.id] = primary_url elif job.product.image: product_image_map[job.id] = job.product.image.url else: product_image_map[job.id] = None return { "customer_name_map": customer_name_map, "printing_order_map": printing_order_map, "external_order_id_map": external_order_id_map, "product_image_map": product_image_map, } def _get_printing_jobs_for_shipment_ids(*, merchant, shipment_ids): from printing.models import PrintingJob printing_job_ids = ( SalesItem.objects.filter( merchant=merchant, shipment_id__in=shipment_ids, delete_at__isnull=True, printing_job_id__isnull=False, ) .values_list("printing_job_id", flat=True) .distinct() ) return ( PrintingJob.objects.filter(id__in=printing_job_ids) .select_related("printing_order", "printing_order__customer") .order_by("id") ) class ShipmentListCreateView(ListModelMixin, GenericAPIView): """ 出货单:查询列表 / 创建 - GET /api/v1/shipment/shipments/ - POST /api/v1/shipment/shipments/ 请求体: { "customer": 1, "shipment_date": "2026-01-14", "remark": "备注信息(可选)", "sales_items": [1, 2, 3] } 返回: { "id": 1, "customer": 1, "customer_name": "客户A", "shipment_date": "2026-01-14", "remark": "备注信息", "items_count": 3, "created_by_id": 1, "created_by_name": "张三", "created_at": "2026-01-14T10:00:00Z", "updated_at": "2026-01-14T10:00:00Z" } """ permission_classes = [IsAuthenticated] serializer_class = ShipmentSerializer pagination_class = LimitedLimitOffsetPagination def get_queryset(self): """ 仅返回当前用户所属商户的出货单(merchant 隔离)。 支持过滤参数(可选): - customer: 客户ID - status: 状态(1=草稿, 2=已发布, 3=已取消, 4=已驳回, 5=已审核) - delivery_id: 送货单ID(仅当传入具体ID时过滤;null/空值不触发过滤) - delivery_isnull: 是否仅查询未绑定/已绑定送货单的出货单(true/false) - shipment_stage: 逻辑状态(missing_address/deliverable/scheduled/delivered/cancelled) - external_id: 外部订单号(精确匹配) - shipment_date_from: 出货日期起始(YYYY-MM-DD) - shipment_date_to: 出货日期结束(YYYY-MM-DD,包含整天) """ qs = ( Shipment.objects.all() .select_related("merchant", "customer", "created_by", "cancelled_by", "delivery") .prefetch_related( "items", "external_finished_products", ) ) user = self.request.user if not getattr(user, "is_superuser", False): emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Shipment.objects.none() qs = qs.filter(merchant=merchant) # optional filters customer_id = self.request.query_params.get("customer") if customer_id: qs = qs.filter(customer_id=customer_id) status_val = self.request.query_params.get("status") if status_val: qs = qs.filter(status=status_val) delivery_id = self.request.query_params.get("delivery_id") if delivery_id is not None: normalized_delivery_id = delivery_id.strip() if normalized_delivery_id and normalized_delivery_id.lower() not in {"null", "none"}: qs = qs.filter(delivery_id=normalized_delivery_id) delivery_isnull = self.request.query_params.get("delivery_isnull") if delivery_isnull is not None: normalized_delivery_isnull = delivery_isnull.strip().lower() if normalized_delivery_isnull in {"1", "true", "yes"}: qs = qs.filter(delivery_id__isnull=True) elif normalized_delivery_isnull in {"0", "false", "no"}: qs = qs.filter(delivery_id__isnull=False) external_id = self.request.query_params.get("external_id") if external_id: qs = qs.filter(external_id=external_id) date_from = self.request.query_params.get("shipment_date_from") if date_from: qs = qs.filter(shipment_date__gte=date_from) date_to = self.request.query_params.get("shipment_date_to") if date_to: # 通过 <= 过滤日期 qs = qs.filter(shipment_date__lte=date_to) only_address_null = self.request.query_params.get("only_address_null") if only_address_null is not None: normalized = only_address_null.strip().lower() if normalized in {"1", "true", "yes"}: qs = qs.filter(address="") elif normalized in {"0", "false", "no"}: qs = qs.exclude(address="") shipment_stage = self.request.query_params.get("shipment_stage") if shipment_stage: normalized_stage = shipment_stage.strip() if normalized_stage not in SHIPMENT_STAGE_CHOICES: allowed = ", ".join(sorted(SHIPMENT_STAGE_CHOICES)) raise ValidationError({"shipment_stage": f"shipment_stage 必须是 {allowed} 之一"}) if normalized_stage == SHIPMENT_STAGE_CANCELLED: qs = qs.filter(status=ShipmentStatus.CANCELLED) else: qs = qs.exclude(status=ShipmentStatus.CANCELLED) if normalized_stage == SHIPMENT_STAGE_MISSING_ADDRESS: qs = qs.filter(delivery_id__isnull=True, address="") elif normalized_stage == SHIPMENT_STAGE_DELIVERABLE: qs = qs.filter(delivery_id__isnull=True).exclude(address="") elif normalized_stage == SHIPMENT_STAGE_SCHEDULED: qs = qs.filter( delivery_id__isnull=False, delivery__status__in=[ ShipmentDeliveryStatus.PENDING, ShipmentDeliveryStatus.IN_TRANSIT, ], ) elif normalized_stage == SHIPMENT_STAGE_DELIVERED: qs = qs.filter( delivery_id__isnull=False, delivery__status=ShipmentDeliveryStatus.DELIVERED, ) return qs.order_by("-created_at", "-id") def get(self, request): return self.list(request) def post(self, request): # 验证请求数据 serializer = ShipmentCreateNormalSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) data = serializer.validated_data # 调用业务逻辑 from shipment.services import create_shipment try: shipment = create_shipment( customer_id=data["customer"], shipment_date=data["shipment_date"], sales_item_ids=data["sales_items"], created_by=request.user, address_id=data.get("address_id"), address=data.get("address", ""), contact_name=data.get("contact_name", ""), contact_phone=data.get("contact_phone", ""), remark=data.get("remark", ""), area=data.get("area", ""), coordinates=data.get("coordinates"), extra=data.get("extra"), status=data.get("status"), ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) # 返回创建的出货单 response_serializer = ShipmentSerializer(shipment) return Response(response_serializer.data, status=status.HTTP_201_CREATED) class ShipmentDetailView(RetrieveModelMixin, GenericAPIView): """ 出货单详情 GET /api/v1/shipment/shipments// """ permission_classes = [IsAuthenticated] serializer_class = ShipmentSerializer def get_queryset(self): qs = ( Shipment.objects.all() .select_related("merchant", "customer", "created_by", "cancelled_by") .prefetch_related( "items", "external_finished_products", ) ) user = self.request.user if getattr(user, "is_superuser", False): return qs emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Shipment.objects.none() qs = qs.filter(merchant=merchant) status_val = self.request.query_params.get("status") if status_val: qs = qs.filter(status=status_val) return qs def get(self, request, pk: int): return self.retrieve(request, pk=pk) def patch(self, request, pk: int): """ 更新出货单(部分更新) PATCH /api/v1/shipment/shipments// """ shipment = self.get_object() serializer = ShipmentUpdateSerializer(data=request.data, partial=True) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) data = serializer.validated_data from shipment.services import UNSET, update_shipment try: shipment = update_shipment( shipment, customer_id=data.get("customer"), shipment_date=data.get("shipment_date"), address=data.get("address"), contact_name=data.get("contact_name"), contact_phone=data.get("contact_phone"), area=data.get("area"), coordinates=data["coordinates"] if "coordinates" in data else UNSET, remark=data.get("remark"), external_id=data.get("external_id"), extra=data["extra"] if "extra" in data else UNSET, ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(ShipmentSerializer(shipment).data, status=status.HTTP_200_OK) def put(self, request, pk: int): """ 更新出货单(全量更新,当前实现允许缺省字段,等同于 PATCH) PUT /api/v1/shipment/shipments// """ return self.patch(request, pk=pk) class ShipmentStatusUpdateView(APIView): """ 更新出货单状态 PATCH /api/v1/shipment/shipments//status/ PUT /api/v1/shipment/shipments//status/ """ permission_classes = [IsAuthenticated] def get_queryset(self): qs = Shipment.objects.all().select_related( "merchant", "customer", "created_by", "cancelled_by", "approved_by", ).prefetch_related( "items", "external_finished_products", ) user = self.request.user if getattr(user, "is_superuser", False): return qs emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Shipment.objects.none() return qs.filter(merchant=merchant) def patch(self, request, pk: int): shipment = self.get_queryset().filter(id=pk).first() if shipment is None: return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) serializer = ShipmentStatusUpdateSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) from shipment.models import ShipmentStatus from shipment.services import modify_status target_status = serializer.validated_data["status"] approved_by = request.user if target_status == ShipmentStatus.APPROVED else None try: shipment = modify_status( shipment, target_status=target_status, operator=request.user, approved_by=approved_by, ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(ShipmentSerializer(shipment).data, status=status.HTTP_200_OK) def put(self, request, pk: int): return self.patch(request, pk=pk) class ShipmentPrintingJobListView(GenericAPIView): permission_classes = [IsAuthenticated] serializer_class = ShipmentPrintingJobSummarySerializer pagination_class = LimitedLimitOffsetPagination def get_shipment_queryset(self): qs = Shipment.objects.all().select_related("merchant") user = self.request.user if getattr(user, "is_superuser", False): return qs emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Shipment.objects.none() return qs.filter(merchant=merchant) def get(self, request, pk: int): shipment = self.get_shipment_queryset().filter(id=pk).first() if shipment is None: return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) queryset = _get_printing_jobs_for_shipment_ids( merchant=shipment.merchant, shipment_ids=[shipment.id], ) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) class ShipmentDeliveryListCreateView(ListModelMixin, GenericAPIView): """ 送货单:查询列表 / 创建 - GET /api/v1/shipment/deliveries/ - POST /api/v1/shipment/deliveries/ """ permission_classes = [IsAuthenticated] serializer_class = ShipmentDeliverySerializer pagination_class = LimitedLimitOffsetPagination def get_queryset(self): qs = ShipmentDelivery.objects.all().select_related( "merchant", "created_by", "operator", "cancelled_by" ).prefetch_related( "shipments", "shipments__customer", "shipments__customer_address", ) user = self.request.user if not getattr(user, "is_superuser", False): emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return ShipmentDelivery.objects.none() qs = qs.filter(merchant=merchant) status_val = self.request.query_params.get("status") if status_val: qs = qs.filter(status=status_val) driver_name = (self.request.query_params.get("driver_name") or "").strip() if driver_name: qs = qs.filter(driver_name__icontains=driver_name) vehicle_trip = (self.request.query_params.get("vehicle_trip") or "").strip() if vehicle_trip: qs = qs.filter(vehicle_trip__icontains=vehicle_trip) return qs.order_by("-created_at", "-id") def get(self, request): return self.list(request) def post(self, request): serializer = ShipmentDeliveryCreateSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) from shipment.services import create_shipment_delivery try: delivery = create_shipment_delivery( driver_name=serializer.validated_data["driver_name"], vehicle_trip=serializer.validated_data["vehicle_trip"], contact_phone=serializer.validated_data.get("contact_phone", ""), vehicle_capacity=serializer.validated_data.get("vehicle_capacity", ""), shipment_ids=serializer.validated_data.get("shipments", []), shipment_order_ids=serializer.validated_data.get("shipment_order_ids", []), created_by=request.user, remark=serializer.validated_data.get("remark", ""), internal_remark=serializer.validated_data.get("internal_remark", ""), ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) delivery = ShipmentDelivery.objects.select_related( "merchant", "created_by", "operator", "cancelled_by" ).prefetch_related( "shipments", "shipments__customer", "shipments__customer_address", ).get(id=delivery.id) return Response( ShipmentDeliverySerializer(delivery).data, status=status.HTTP_201_CREATED, ) class ShipmentDeliveryByPrintingOrderView(GenericAPIView): """ 按生产订单查询相关送货单。 GET /api/v1/shipment/deliveries/by-printing-order// """ permission_classes = [IsAuthenticated] serializer_class = ShipmentDeliveryByPrintingOrderSerializer pagination_class = LimitedLimitOffsetPagination ordering_fields = { "id", "created_at", "updated_at", "started_at", "delivered_at", "cancelled_at", "status", } def _get_merchant_limited_printing_orders(self): from printing.models import PrintingOrder queryset = PrintingOrder.objects.all() user = self.request.user if getattr(user, "is_superuser", False): return queryset emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return queryset.none() return queryset.filter(merchant=merchant) def _resolve_printing_order(self, printing_order_id: str): raw_value = str(printing_order_id).strip() base_queryset = self._get_merchant_limited_printing_orders() if raw_value.isdigit(): printing_order = base_queryset.filter(id=int(raw_value)).first() if printing_order is not None: return printing_order, None matched_orders = list( base_queryset.filter(external_order_id=raw_value).only("id", "merchant_id")[:2] ) if len(matched_orders) > 1: return None, Response( { "message": ( f"external_order_id {raw_value} 匹配到多个生产订单," "请改用内部ID查询" ) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) if len(matched_orders) == 1: return matched_orders[0], None return None, Response( {"detail": f"生产订单 {printing_order_id} 不存在"}, status=status.HTTP_404_NOT_FOUND, ) def _apply_datetime_range(self, queryset, field_name: str, from_param: str, to_param: str): start_value = (self.request.query_params.get(from_param) or "").strip() if start_value: if "T" in start_value or ":" in start_value: parsed = parse_datetime(start_value) if parsed is not None: queryset = queryset.filter(**{f"{field_name}__gte": parsed}) else: parsed = parse_date(start_value) if parsed is not None: queryset = queryset.filter(**{f"{field_name}__date__gte": parsed}) end_value = (self.request.query_params.get(to_param) or "").strip() if end_value: if "T" in end_value or ":" in end_value: parsed = parse_datetime(end_value) if parsed is not None: queryset = queryset.filter(**{f"{field_name}__lte": parsed}) else: parsed = parse_date(end_value) if parsed is not None: queryset = queryset.filter(**{f"{field_name}__date__lte": parsed}) return queryset def _apply_filters(self, queryset): status_val = self.request.query_params.get("status") if status_val: queryset = queryset.filter(status=status_val) for field_name in [ "driver_name", "vehicle_trip", "contact_phone", "vehicle_capacity", "remark", "internal_remark", ]: value = (self.request.query_params.get(field_name) or "").strip() if value: queryset = queryset.filter(**{f"{field_name}__icontains": value}) queryset = self._apply_datetime_range( queryset, "started_at", "started_at_from", "started_at_to" ) queryset = self._apply_datetime_range( queryset, "delivered_at", "delivered_at_from", "delivered_at_to" ) queryset = self._apply_datetime_range( queryset, "cancelled_at", "cancelled_at_from", "cancelled_at_to" ) queryset = self._apply_datetime_range( queryset, "created_at", "created_at_from", "created_at_to" ) shipment_date_from = (self.request.query_params.get("shipment_date_from") or "").strip() if shipment_date_from: queryset = queryset.filter(shipments__shipment_date__gte=shipment_date_from) shipment_date_to = (self.request.query_params.get("shipment_date_to") or "").strip() if shipment_date_to: queryset = queryset.filter(shipments__shipment_date__lte=shipment_date_to) ordering = (self.request.query_params.get("ordering") or "").strip() if ordering: normalized = ordering[1:] if ordering.startswith("-") else ordering if normalized in self.ordering_fields: return queryset.order_by(ordering, "-id").distinct() return queryset.order_by("-created_at", "-id").distinct() def get(self, request, printing_order_id: str): printing_order, error_response = self._resolve_printing_order(printing_order_id) if error_response is not None: return error_response from printing.models import PrintingJob printing_job_ids = list( PrintingJob.objects.filter(printing_order=printing_order).values_list( "id", flat=True ) ) if not printing_job_ids: queryset = ShipmentDelivery.objects.none() else: queryset = ( ShipmentDelivery.objects.filter( merchant=printing_order.merchant, shipments__items__printing_job_id__in=printing_job_ids, shipments__items__delete_at__isnull=True, ) .select_related("merchant", "created_by", "operator", "cancelled_by") .prefetch_related( "shipments", "shipments__customer", "shipments__customer_address", ) ) queryset = self._apply_filters(queryset) page = self.paginate_queryset(queryset) serializer = self.get_serializer(page if page is not None else queryset, many=True) if page is not None: return self.get_paginated_response(serializer.data) return Response(serializer.data) class ShipmentDeliveryDetailView(RetrieveModelMixin, GenericAPIView): """ 送货单详情 / 修改 / 删除 - GET /api/v1/shipment/deliveries// - PATCH /api/v1/shipment/deliveries// - PUT /api/v1/shipment/deliveries// - DELETE /api/v1/shipment/deliveries// """ permission_classes = [IsAuthenticated] serializer_class = ShipmentDeliverySerializer def get_queryset(self): qs = ShipmentDelivery.objects.all().select_related( "merchant", "created_by", "operator", "cancelled_by" ).prefetch_related( "shipments", "shipments__customer", "shipments__customer_address", ) user = self.request.user if getattr(user, "is_superuser", False): return qs emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return ShipmentDelivery.objects.none() return qs.filter(merchant=merchant) def get(self, request, pk: int): return self.retrieve(request, pk=pk) def patch(self, request, pk: int): delivery = self.get_object() serializer = ShipmentDeliveryUpdateSerializer(data=request.data, partial=True) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) from shipment.services import UNSET, update_shipment_delivery try: delivery = update_shipment_delivery( delivery, driver_name=serializer.validated_data.get("driver_name"), vehicle_trip=serializer.validated_data.get("vehicle_trip"), contact_phone=serializer.validated_data.get("contact_phone"), vehicle_capacity=serializer.validated_data.get("vehicle_capacity"), shipment_ids=serializer.validated_data.get("shipments"), shipment_order_ids=( serializer.validated_data["shipment_order_ids"] if "shipment_order_ids" in serializer.validated_data else UNSET ), remark=serializer.validated_data.get("remark"), internal_remark=serializer.validated_data.get("internal_remark"), operator=request.user, ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) delivery = self.get_queryset().get(id=delivery.id) return Response( ShipmentDeliverySerializer(delivery).data, status=status.HTTP_200_OK, ) def put(self, request, pk: int): return self.patch(request, pk=pk) def delete(self, request, pk: int): delivery = self.get_object() from shipment.services import delete_shipment_delivery delete_shipment_delivery(delivery) return Response(status=status.HTTP_204_NO_CONTENT) class ShipmentDeliveryPrintingJobListView(GenericAPIView): permission_classes = [IsAuthenticated] serializer_class = ShipmentPrintingJobSummarySerializer pagination_class = LimitedLimitOffsetPagination def get_delivery_queryset(self): qs = ShipmentDelivery.objects.all().select_related("merchant") user = self.request.user if getattr(user, "is_superuser", False): return qs emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return ShipmentDelivery.objects.none() return qs.filter(merchant=merchant) def get(self, request, pk: int): delivery = self.get_delivery_queryset().filter(id=pk).first() if delivery is None: return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) shipment_ids = delivery.shipments.values_list("id", flat=True) queryset = _get_printing_jobs_for_shipment_ids( merchant=delivery.merchant, shipment_ids=shipment_ids, ) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) class ShipmentDeliveryStatusUpdateView(APIView): """ 修改送货单状态 POST /api/v1/shipment/deliveries//status/ """ permission_classes = [IsAuthenticated] def post(self, request, pk: int): user = request.user queryset = ShipmentDelivery.objects.all() if not getattr(user, "is_superuser", False): emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND) queryset = queryset.filter(merchant=merchant) delivery = queryset.filter(id=pk).first() if delivery is None: return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND) serializer = ShipmentDeliveryStatusUpdateSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) from shipment.services import modify_shipment_delivery_status try: delivery = modify_shipment_delivery_status( delivery, target_status=serializer.validated_data["status"], operator=request.user, ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) delivery = ShipmentDelivery.objects.select_related( "merchant", "created_by", "operator", "cancelled_by" ).prefetch_related( "shipments", "shipments__customer", "shipments__customer_address", ).get(id=delivery.id) return Response( ShipmentDeliverySerializer(delivery).data, status=status.HTTP_200_OK, ) class ShipmentDeliveryCancelView(APIView): """ 取消送货单 POST /api/v1/shipment/deliveries//cancel/ """ permission_classes = [IsAuthenticated] def post(self, request, pk: int): if not request.user.has_perm("shipment.cancel_shipmentdelivery"): return Response( {"detail": "没有权限取消送货单"}, status=status.HTTP_403_FORBIDDEN, ) user = request.user queryset = ShipmentDelivery.objects.all() if not getattr(user, "is_superuser", False): emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND) queryset = queryset.filter(merchant=merchant) delivery = queryset.filter(id=pk).first() if delivery is None: return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND) from shipment.services import cancel_shipment_delivery delivery = cancel_shipment_delivery( delivery, cancelled_by=request.user, operator=request.user, ) delivery = ShipmentDelivery.objects.select_related( "merchant", "created_by", "operator", "cancelled_by" ).prefetch_related( "shipments", "shipments__customer", "shipments__customer_address", ).get(id=delivery.id) return Response( ShipmentDeliverySerializer(delivery).data, status=status.HTTP_200_OK, ) class ShipmentDeliveryBindShipmentsView(APIView): """ 追加绑定出货单到现有送货单 POST /api/v1/shipment/deliveries//bind-shipments/ """ permission_classes = [IsAuthenticated] def post(self, request, pk: int): user = request.user queryset = ShipmentDelivery.objects.all() if not getattr(user, "is_superuser", False): emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND) queryset = queryset.filter(merchant=merchant) delivery = queryset.filter(id=pk).first() if delivery is None: return Response({"detail": "未找到送货单"}, status=status.HTTP_404_NOT_FOUND) serializer = ShipmentDeliveryBindShipmentsSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) from shipment.services import bind_shipments_to_delivery try: delivery = bind_shipments_to_delivery( delivery, shipment_ids=serializer.validated_data["shipments"], operator=request.user, ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) delivery = ShipmentDelivery.objects.select_related( "merchant", "created_by", "operator", "cancelled_by" ).prefetch_related( "shipments", "shipments__customer", "shipments__customer_address", ).get(id=delivery.id) return Response( ShipmentDeliverySerializer(delivery).data, status=status.HTTP_200_OK, ) class ShipmentExternalCreateView(APIView): """ 创建出货单(external 版) POST /api/v1/shipment/shipments/external/ 特点: - external_id 必填 - external_finished_products 必填(数组) - 不绑定任何销售品 """ permission_classes = [IsAuthenticated] def post(self, request): serializer = ShipmentCreateExternalSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) data = serializer.validated_data from shipment.services import create_external_shipment try: shipment = create_external_shipment( customer_id=data["customer"], shipment_date=data["shipment_date"], external_id=data["external_id"], external_finished_products=data["external_finished_products"], created_by=request.user, address=data.get("address", ""), contact_name=data.get("contact_name", ""), contact_phone=data.get("contact_phone", ""), remark=data.get("remark", ""), area=data.get("area", ""), coordinates=data.get("coordinates"), extra=data.get("extra"), ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) response_serializer = ShipmentSerializer(shipment) return Response(response_serializer.data, status=status.HTTP_201_CREATED) class ShipmentSalesItemCustomerListView(GenericAPIView): """ 从未出货销售品反推出“当前可出货客户”列表。 GET /api/v1/shipment/sales-items/customers/ """ permission_classes = [IsAuthenticated] serializer_class = ShipmentSalesItemCustomerSerializer pagination_class = LimitedLimitOffsetPagination def get_queryset(self): user = self.request.user if getattr(user, "is_superuser", False): from basic_info.models import Customer, Merchant merchant_id = self.request.query_params.get("merchant") if not merchant_id: return Customer.objects.none() try: merchant = Merchant.objects.get(id=merchant_id) except Merchant.DoesNotExist: return Customer.objects.none() else: from basic_info.models import Customer emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Customer.objects.none() from shipment.services import get_customers_with_unshipped_sales_items qs = get_customers_with_unshipped_sales_items(merchant=merchant) customer_name = self.request.query_params.get("customer_name") if customer_name: customer_name = customer_name.strip() if customer_name: qs = qs.filter(name__icontains=customer_name) return qs def get(self, request): queryset = self.get_queryset() page = self.paginate_queryset(queryset) serializer = self.get_serializer(page if page is not None else queryset, many=True) if page is not None: return self.get_paginated_response(serializer.data) return Response(serializer.data) class SalesItemByPrintingOrderView(APIView): """ 通过生产订单查询销售品 GET /api/v1/shipment/sales-items/by-printing-order// 返回与该 PrintingOrder 下所有 PrintingJob 关联的 SalesItem 列表。 路径参数既支持内部 ID,也支持 external_order_id。 查询参数: - include_already_has_shipment: 是否包含已关联出货单的销售品(true/false),默认 false 返回: { "count": 5, "results": [ { "id": 1, "name": "产品A", "quantity": "100.00", "unit": 1, "unit_display": "米", "position": "A1-01", "remark": "", "printing_job_id": 123, "customer_id": null, "shipment_id": null, "shipment_date": null, "created_at": "2026-01-14T10:00:00Z", "created_by_id": 1, "created_by_name": "张三" }, ... ] } """ permission_classes = [IsAuthenticated] def get(self, request, printing_order_id): from printing.models import PrintingOrder user = request.user if getattr(user, "is_superuser", False): base_queryset = PrintingOrder.objects.all() else: emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Response( {"detail": f"生产订单 {printing_order_id} 不存在"}, status=status.HTTP_404_NOT_FOUND, ) base_queryset = PrintingOrder.objects.filter(merchant=merchant) printing_order = None if printing_order_id.isdigit(): printing_order = base_queryset.filter(id=int(printing_order_id)).first() if printing_order is None: matched_orders = list( base_queryset.filter(external_order_id=printing_order_id).only("id")[:2] ) if len(matched_orders) > 1: return Response( { "detail": ( f"external_order_id {printing_order_id} 匹配到多个生产订单," "请改用内部ID查询" ) }, status=status.HTTP_400_BAD_REQUEST, ) if len(matched_orders) == 1: printing_order = matched_orders[0] if printing_order is None: return Response( {"detail": f"生产订单 {printing_order_id} 不存在"}, status=status.HTTP_404_NOT_FOUND, ) # 获取查询参数 include_already_has_shipment = ( request.query_params.get("include_already_has_shipment", "false").lower() == "true" ) # 调用 shipment 业务逻辑 from shipment.services import get_sales_items_by_printing_order sales_items = get_sales_items_by_printing_order( printing_order_id=printing_order_id, include_already_has_shipment=include_already_has_shipment, merchant=printing_order.merchant, ) # 序列化返回 items = list(sales_items) serializer = SalesItemSerializer( items, many=True, context=_build_sales_item_serializer_context(items), ) return Response({"count": len(serializer.data), "results": serializer.data}) class SalesItemByPrintingOrderGroupView(SalesItemByPrintingOrderView): """ 通过生产订单查询销售品,并按 printing_job_id 分组分页。 GET /api/v1/shipment/sales-items/by-printing-order//group/ """ pagination_class = LimitedLimitOffsetPagination @property def paginator(self): if not hasattr(self, "_paginator"): self._paginator = self.pagination_class() return self._paginator def paginate_queryset(self, queryset): return self.paginator.paginate_queryset(queryset, self.request, view=self) def get_paginated_response(self, data): return self.paginator.get_paginated_response(data) def get(self, request, printing_order_id): from printing.models import PrintingOrder user = request.user if getattr(user, "is_superuser", False): base_queryset = PrintingOrder.objects.all() else: emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Response( {"detail": f"生产订单 {printing_order_id} 不存在"}, status=status.HTTP_404_NOT_FOUND, ) base_queryset = PrintingOrder.objects.filter(merchant=merchant) printing_order = None if printing_order_id.isdigit(): printing_order = base_queryset.filter(id=int(printing_order_id)).first() if printing_order is None: matched_orders = list( base_queryset.filter(external_order_id=printing_order_id).only("id")[:2] ) if len(matched_orders) > 1: return Response( { "detail": ( f"external_order_id {printing_order_id} 匹配到多个生产订单," "请改用内部ID查询" ) }, status=status.HTTP_400_BAD_REQUEST, ) if len(matched_orders) == 1: printing_order = matched_orders[0] if printing_order is None: return Response( {"detail": f"生产订单 {printing_order_id} 不存在"}, status=status.HTTP_404_NOT_FOUND, ) include_already_has_shipment = ( request.query_params.get("include_already_has_shipment", "false").lower() == "true" ) from shipment.services import get_sales_items_by_printing_order queryset = get_sales_items_by_printing_order( printing_order_id=printing_order_id, include_already_has_shipment=include_already_has_shipment, merchant=printing_order.merchant, ) all_items = list(queryset) printing_job_ids = [] seen_printing_job_ids = set() for item in all_items: group_key = item.printing_job_id if group_key not in seen_printing_job_ids: seen_printing_job_ids.add(group_key) printing_job_ids.append(group_key) printing_job_ids.sort(key=lambda value: (value is None, value or 0)) page_printing_job_ids = self.paginate_queryset(printing_job_ids) selected_printing_job_ids = ( page_printing_job_ids if page_printing_job_ids is not None else printing_job_ids ) selected_printing_job_id_set = set(selected_printing_job_ids) selected_items = [ item for item in all_items if item.printing_job_id in selected_printing_job_id_set ] serializer = SalesItemSerializer( selected_items, many=True, context=_build_sales_item_serializer_context(selected_items), ) grouped_by_printing_job_id = { printing_job_id: { "printing_job_id": printing_job_id, "count": 0, "items": [], } for printing_job_id in selected_printing_job_ids } for item in serializer.data: printing_job_id = item["printing_job_id"] group = grouped_by_printing_job_id[printing_job_id] group["count"] += 1 group["items"].append(item) groups = [ grouped_by_printing_job_id[printing_job_id] for printing_job_id in selected_printing_job_ids ] if page_printing_job_ids is not None: return self.get_paginated_response(groups) return Response(groups) class SalesItemByCustomerView(GenericAPIView): """ 通过客户查询销售品。 GET /api/v1/shipment/sales-items/by-customer// """ permission_classes = [IsAuthenticated] serializer_class = SalesItemSerializer pagination_class = LimitedLimitOffsetPagination def get(self, request, customer_id: int): from basic_info.models import Customer user = request.user if getattr(user, "is_superuser", False): customer = Customer.objects.filter(id=customer_id).first() else: emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Response( {"detail": f"客户 {customer_id} 不存在"}, status=status.HTTP_404_NOT_FOUND, ) customer = Customer.objects.filter(id=customer_id, merchant=merchant).first() if customer is None: return Response( {"detail": f"客户 {customer_id} 不存在"}, status=status.HTTP_404_NOT_FOUND, ) include_already_has_shipment = ( request.query_params.get("include_already_has_shipment", "false").lower() == "true" ) external_order_id = (request.query_params.get("external_order_id") or "").strip() from shipment.services import get_sales_items_by_customer queryset = get_sales_items_by_customer( merchant=customer.merchant, customer_id=customer.id, include_already_has_shipment=include_already_has_shipment, external_order_id=external_order_id or None, ) page = self.paginate_queryset(queryset) items = list(page) if page is not None else list(queryset) serializer = self.get_serializer( items, many=True, context=_build_sales_item_serializer_context(items), ) if page is not None: return self.get_paginated_response(serializer.data) return Response(serializer.data) class SalesItemByCustomerGroupView(SalesItemByCustomerView): """ 通过客户查询销售品,并按 external_order_id、name 分组。 GET /api/v1/shipment/sales-items/by-customer//group/ """ def get(self, request, customer_id: int): from basic_info.models import Customer user = request.user if getattr(user, "is_superuser", False): customer = Customer.objects.filter(id=customer_id).first() else: emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Response( {"detail": f"客户 {customer_id} 不存在"}, status=status.HTTP_404_NOT_FOUND, ) customer = Customer.objects.filter(id=customer_id, merchant=merchant).first() if customer is None: return Response( {"detail": f"客户 {customer_id} 不存在"}, status=status.HTTP_404_NOT_FOUND, ) include_already_has_shipment = ( request.query_params.get("include_already_has_shipment", "false").lower() == "true" ) external_order_id = (request.query_params.get("external_order_id") or "").strip() from shipment.services import get_sales_items_by_customer queryset = get_sales_items_by_customer( merchant=customer.merchant, customer_id=customer.id, include_already_has_shipment=include_already_has_shipment, external_order_id=external_order_id or None, ) all_items = list(queryset) serializer_context = _build_sales_item_serializer_context(all_items) serialized_items = self.get_serializer( all_items, many=True, context=serializer_context, ).data external_order_ids = [] seen_external_order_ids = set() for item in serialized_items: group_key = item["external_order_id"] if group_key not in seen_external_order_ids: seen_external_order_ids.add(group_key) external_order_ids.append(group_key) external_order_ids.sort(key=lambda value: (value is None, value or "")) page_external_order_ids = self.paginate_queryset(external_order_ids) selected_external_order_ids = ( page_external_order_ids if page_external_order_ids is not None else external_order_ids ) selected_external_order_id_set = set(selected_external_order_ids) grouped_by_external_order_id = { external_order_id: { "external_order_id": external_order_id, "count": 0, "name_groups": [], } for external_order_id in selected_external_order_ids } name_group_maps = { external_order_id: {} for external_order_id in selected_external_order_ids } for item in serialized_items: external_order_id = item["external_order_id"] if external_order_id not in selected_external_order_id_set: continue external_group = grouped_by_external_order_id[external_order_id] external_group["count"] += 1 name = item["name"] name_group_map = name_group_maps[external_order_id] if name not in name_group_map: name_group_map[name] = { "name": name, "count": 0, "items": [], } external_group["name_groups"].append(name_group_map[name]) name_group = name_group_map[name] name_group["count"] += 1 name_group["items"].append(item) groups = [ grouped_by_external_order_id[external_order_id] for external_order_id in selected_external_order_ids ] if page_external_order_ids is not None: return self.get_paginated_response(groups) return Response(groups) class SalesItemCreateView(APIView): """ 手动创建销售品 POST /api/v1/shipment/sales-items/ 当自动转化销售品开关关闭时,通过此接口手动创建销售品。 请求体: { "printing_job_id": 123, "name": "产品名称", "quantity": "100.50", "unit": 1, "customer_id": 456, "remark": "备注信息", "position": "A1-01" } 参数说明: - printing_job_id: 生产任务ID(必填) - name: 销售品名称(必填) - quantity: 数量(必填,支持小数) - unit: 单位(必填):1=米, 2=件, 3=码, 4=个 - customer_id: 客户ID(可选,默认从生产订单获取) - remark: 备注(可选) - position: 货位(可选) 返回: { "id": 1, "name": "产品名称", "quantity": "100.50", "unit": 1, "unit_display": "米", "position": "A1-01", "remark": "备注信息", "printing_job_id": 123, "customer_id": 456, "shipment_id": null, "shipment_date": null, "created_at": "2026-01-14T10:00:00Z", "created_by_id": 1, "created_by_name": "张三" } """ permission_classes = [IsAuthenticated] def post(self, request): from .serializers import SalesItemCreateSerializer serializer = SalesItemCreateSerializer( data=request.data, context={"request": request} ) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) try: sales_item = serializer.save() except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) # 返回创建的销售品 response_serializer = SalesItemSerializer( sales_item, context=_build_sales_item_serializer_context([sales_item]), ) return Response(response_serializer.data, status=status.HTTP_201_CREATED) class SalesItemDetailView(GenericAPIView): """ 销售品详情。 GET /api/v1/shipment/sales-items// PATCH /api/v1/shipment/sales-items// """ permission_classes = [IsAuthenticated] serializer_class = SalesItemDetailSerializer def get_queryset(self): from shipment.models import SalesItem from shipment.services import get_active_sales_items_queryset qs = ( get_active_sales_items_queryset() .select_related("shipment", "created_by", "delete_by") .order_by("id") ) user = self.request.user if getattr(user, "is_superuser", False): return qs emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return qs.none() return qs.filter(merchant=merchant) def get(self, request, pk: int): sales_item = self.get_queryset().filter(id=pk).first() if sales_item is None: return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) serializer = self.get_serializer( sales_item, context={ **_build_sales_item_serializer_context([sales_item]), "request": request, }, ) return Response(serializer.data) def patch(self, request, pk: int): sales_item = self.get_queryset().filter(id=pk).first() if sales_item is None: return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) allowed_fields = {"quantity", "remark", "position"} unexpected_fields = sorted(set(request.data.keys()) - allowed_fields) if unexpected_fields: return Response( { "detail": ( "销售品仅允许修改以下字段: " f"{', '.join(sorted(allowed_fields))};" f"不支持字段: {', '.join(unexpected_fields)}" ) }, status=status.HTTP_400_BAD_REQUEST, ) serializer = SalesItemUpdateSerializer(data=request.data, partial=True) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) from shipment.services import update_sales_item try: sales_item = update_sales_item( sales_item, quantity=serializer.validated_data.get("quantity"), remark=serializer.validated_data.get("remark"), position=serializer.validated_data.get("position"), operator=request.user, ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) response_serializer = self.get_serializer( sales_item, context={ **_build_sales_item_serializer_context([sales_item]), "request": request, }, ) return Response(response_serializer.data, status=status.HTTP_200_OK) def delete(self, request, pk: int): if not request.user.has_perm("shipment.soft_delete_salesitem"): return Response( {"detail": "没有权限删除销售品"}, status=status.HTTP_403_FORBIDDEN, ) sales_item = self.get_queryset().filter(id=pk).first() if sales_item is None: return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) from shipment.services import delete_sales_item try: delete_sales_item(sales_item, deleted_by=request.user) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK) class SalesItemRebuildView(GenericAPIView): """ 销售品重建。 POST /api/v1/shipment/sales-items//rebuild/ """ permission_classes = [IsAuthenticated] def get_queryset(self): from shipment.services import get_active_sales_items_queryset qs = ( get_active_sales_items_queryset() .select_related("shipment", "created_by") .order_by("id") ) user = self.request.user if getattr(user, "is_superuser", False): return qs emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return qs.none() return qs.filter(merchant=merchant) def post(self, request, pk: int): sales_item = self.get_queryset().filter(id=pk).first() if sales_item is None: return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) if sales_item.created_by_id != request.user.id: return Response( {"detail": "只有销售品创建者才能执行重建"}, status=status.HTTP_403_FORBIDDEN, ) serializer = SalesItemRebuildSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) from shipment.models import SalesItemRebuildRecord from shipment.services import rebuild_sales_item try: new_sales_item = rebuild_sales_item( sales_item, new_printing_job_id=serializer.validated_data["new_printing_job_id"], operator=request.user, quantity=serializer.validated_data.get("quantity"), ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) rebuild_record = SalesItemRebuildRecord.objects.filter( new_sales_item_id=new_sales_item.id ).order_by("-id").first() response_serializer = SalesItemDetailSerializer( new_sales_item, context={ **_build_sales_item_serializer_context([new_sales_item]), "request": request, }, ) return Response( { "detail": "销售品已重建", "old_sales_item_id": sales_item.id, "new_sales_item_id": new_sales_item.id, "rebuild_record_id": rebuild_record.id if rebuild_record else None, "data": response_serializer.data, }, status=status.HTTP_201_CREATED, )