1
0
forked from erp-dev/erp
Files
erpnew/api_v1/views/shipment/views.py
2026-04-02 18:07:06 +08:00

640 lines
21 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 flower.viewsets import LimitedLimitOffsetPagination
from shipment.models import Shipment
from .serializers import (
SalesItemDetailSerializer,
SalesItemSerializer,
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=已审核)
- 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)
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)
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,
remark=data.get("remark", ""),
area=data.get("area", ""),
)
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
# customer 变更需校验 merchant 一致
if "customer" in data:
from basic_info.models import Customer
try:
customer = Customer.objects.get(id=data["customer"])
except Customer.DoesNotExist:
return Response(
{"detail": f"客户 {data['customer']} 不存在"},
status=status.HTTP_400_BAD_REQUEST,
)
if customer.merchant_id != shipment.merchant_id:
return Response(
{"detail": "无权限绑定该客户"}, status=status.HTTP_400_BAD_REQUEST
)
shipment.customer = customer
if "shipment_date" in data:
shipment.shipment_date = data["shipment_date"]
if "area" in data:
shipment.area = (data.get("area") or "").strip()
if "remark" in data:
shipment.remark = data.get("remark") or ""
if "external_id" in data:
shipment.external_id = (data.get("external_id") or "").strip() or None
shipment.save()
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 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,
remark=data.get("remark", ""),
area=data.get("area", ""),
)
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
return get_customers_with_unshipped_sales_items(merchant=merchant)
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 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 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>/
"""
permission_classes = [IsAuthenticated]
serializer_class = SalesItemDetailSerializer
def get_queryset(self):
from shipment.models import SalesItem
qs = SalesItem.objects.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 SalesItem.objects.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)