forked from erp-dev/erp
1760 lines
62 KiB
Python
1760 lines
62 KiB
Python
"""
|
||
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/<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 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/<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",
|
||
"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/<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",
|
||
"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/<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",
|
||
"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/<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",
|
||
"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/<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",
|
||
"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/<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,
|
||
)
|