1
0
forked from erp-dev/erp
Files
erpnew/api_v1/views/shipment/views.py

1615 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Shipment API ViewSet
"""
from rest_framework import status
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 Shipment, ShipmentDelivery
from .serializers import (
SalesItemDetailSerializer,
SalesItemRebuildSerializer,
SalesItemSerializer,
SalesItemUpdateSerializer,
ShipmentStatusUpdateSerializer,
ShipmentDeliveryBindShipmentsSerializer,
ShipmentDeliveryByPrintingOrderSerializer,
ShipmentDeliveryCreateSerializer,
ShipmentDeliverySerializer,
ShipmentDeliveryStatusUpdateSerializer,
ShipmentDeliveryUpdateSerializer,
ShipmentSerializer,
ShipmentCreateNormalSerializer,
ShipmentCreateExternalSerializer,
ShipmentSalesItemCustomerSerializer,
ShipmentUpdateSerializer,
)
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,
}
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
- 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")
.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="")
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/<id>/
"""
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/<id>/
"""
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/<id>/
"""
return self.patch(request, pk=pk)
class ShipmentStatusUpdateView(APIView):
"""
更新出货单状态
PATCH /api/v1/shipment/shipments/<id>/status/
PUT /api/v1/shipment/shipments/<id>/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 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",
)
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",
).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/<printing_order_id>/
"""
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")
)
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/<id>/
- PATCH /api/v1/shipment/deliveries/<id>/
- PUT /api/v1/shipment/deliveries/<id>/
- DELETE /api/v1/shipment/deliveries/<id>/
"""
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",
)
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 ShipmentDeliveryStatusUpdateView(APIView):
"""
修改送货单状态
POST /api/v1/shipment/deliveries/<id>/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",
).get(id=delivery.id)
return Response(
ShipmentDeliverySerializer(delivery).data,
status=status.HTTP_200_OK,
)
class ShipmentDeliveryCancelView(APIView):
"""
取消送货单
POST /api/v1/shipment/deliveries/<id>/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",
).get(id=delivery.id)
return Response(
ShipmentDeliverySerializer(delivery).data,
status=status.HTTP_200_OK,
)
class ShipmentDeliveryBindShipmentsView(APIView):
"""
追加绑定出货单到现有送货单
POST /api/v1/shipment/deliveries/<id>/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",
).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/<printing_order_id>/
返回与该 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/<printing_order_id>/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/<customer_id>/
"""
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/<customer_id>/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/<id>/
PATCH /api/v1/shipment/sales-items/<id>/
"""
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/<id>/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,
)