""" 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// """ permission_classes = [IsAuthenticated] serializer_class = ShipmentSerializer def get_queryset(self): qs = ( Shipment.objects.all() .select_related("merchant", "customer", "created_by", "cancelled_by") .prefetch_related( "items", "external_finished_products", ) ) user = self.request.user if getattr(user, "is_superuser", False): return qs emp = getattr(user, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: return Shipment.objects.none() qs = qs.filter(merchant=merchant) status_val = self.request.query_params.get("status") if status_val: qs = qs.filter(status=status_val) return qs def get(self, request, pk: int): return self.retrieve(request, pk=pk) def patch(self, request, pk: int): """ 更新出货单(部分更新) PATCH /api/v1/shipment/shipments// """ shipment = self.get_object() serializer = ShipmentUpdateSerializer(data=request.data, partial=True) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) data = serializer.validated_data # 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// """ 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// 返回与该 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// """ 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// """ 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)