From f0b017de52962a8ed1559ec1758b1d5fe091a4a0 Mon Sep 17 00:00:00 2001 From: colaftc Date: Tue, 10 Mar 2026 19:30:48 +0800 Subject: [PATCH] feat: salesitem create api --- api_v1/urls.py | 325 ++++++++--- api_v1/views/shipment/serializers.py | 252 ++++++--- api_v1/views/shipment/test_api.py | 792 ++++++++++++++++----------- api_v1/views/shipment/views.py | 234 +++++--- docs/api_v1_sales_item_create.md | 263 +++++++++ shipment/services.py | 186 +++++-- 6 files changed, 1469 insertions(+), 583 deletions(-) create mode 100644 docs/api_v1_sales_item_create.md diff --git a/api_v1/urls.py b/api_v1/urls.py index 1abfc46..430507b 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -22,7 +22,11 @@ from .views.business.statements import views as statement_views from .views.business.pre_sales import views as pre_sales_views from .views.business.pre_purchase import views as pre_purchase_views from .views.stock_change_views.snapshot import StockSnapshotListView -from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet +from .views.printing.views import ( + PrintingOrderViewSet, + PrintingJobViewSet, + PlateOrderViewSet, +) from .views.upload import UploadFileViewSet from .views.products import ProductQuickViewSet from .views.parameters import StateParameterViewSet @@ -30,6 +34,7 @@ from .views.users import CreateUserWithProfileView from .views.mingdaoyun import MDYPlateOrderStagingViewSet from .views.shipment import ( SalesItemByPrintingOrderView, + SalesItemCreateView, ShipmentListCreateView, ShipmentDetailView, ShipmentExternalCreateView, @@ -38,132 +43,282 @@ from .views.settlement.views import PlateOrderSummaryView # 创建 DRF Router for Stateflow stateflow_router = DefaultRouter() -stateflow_router.register(r'states', stateflow.StateViewSet, basename='state') -stateflow_router.register(r'processes', stateflow.ProcessViewSet, basename='process') -stateflow_router.register(r'business-objects', stateflow.BusinessObjectViewSet, basename='business-object') +stateflow_router.register(r"states", stateflow.StateViewSet, basename="state") +stateflow_router.register(r"processes", stateflow.ProcessViewSet, basename="process") +stateflow_router.register( + r"business-objects", stateflow.BusinessObjectViewSet, basename="business-object" +) # 创建主 Router main_router = DefaultRouter() -main_router.register(r'printing-orders', PrintingOrderViewSet, basename='printing-order') -main_router.register(r'printing-jobs', PrintingJobViewSet, basename='printing-job') -main_router.register(r'plate-orders', PlateOrderViewSet, basename='plate-order') -main_router.register(r'upload', UploadFileViewSet, basename='upload') -main_router.register(r'products/quick', ProductQuickViewSet, basename='product-quick') -main_router.register(r'parameters', StateParameterViewSet, basename='parameter') -main_router.register(r'mdy-plate-order-staging', MDYPlateOrderStagingViewSet, basename='mdy-plate-order-staging') +main_router.register( + r"printing-orders", PrintingOrderViewSet, basename="printing-order" +) +main_router.register(r"printing-jobs", PrintingJobViewSet, basename="printing-job") +main_router.register(r"plate-orders", PlateOrderViewSet, basename="plate-order") +main_router.register(r"upload", UploadFileViewSet, basename="upload") +main_router.register(r"products/quick", ProductQuickViewSet, basename="product-quick") +main_router.register(r"parameters", StateParameterViewSet, basename="parameter") +main_router.register( + r"mdy-plate-order-staging", + MDYPlateOrderStagingViewSet, + basename="mdy-plate-order-staging", +) urlpatterns = [ # 库存变动相关API - path('stock-snapshots/', StockSnapshotListView.as_view(), name='list_stock_snapshots'), - path('stock-changes/', stock_change_views.list_stock_changes, name='list_stock_changes'), - path('stock-change-details/', stock_change_views.list_stock_change_details, name='list_stock_change_details'), - path('stock-change/', stock_change_views.create_full_stock_change, name='create_full_stock_change'), - path('stock-change/relaxed/', stock_change_views.create_relaxed_stock_change, name='create_relaxed_stock_change'), - path('stock-change/restrict/', stock_change_views.create_restrict_stock_change, name='create_restrict_stock_change'), - path('stock-change/transfer/', stock_change_views.transfer_stock_change, name='stock_transfer'), - path('stock-change//', stock_change_views.get_stock_change, name='get_stock_change'), - path('stock-change//finish/', stock_change_views.finish_stock_change, name='finish_stock_change'), - path('stock-change//offset/', stock_change_views.offset_stock_change, name='offset_stock_change'), path( - 'set-merchant-auto-complete-stock-change/', - stock_change_views.set_merchant_auto_complete_stock_change, - name='set_merchant_auto_complete_stock_change', + "stock-snapshots/", StockSnapshotListView.as_view(), name="list_stock_snapshots" + ), + path( + "stock-changes/", + stock_change_views.list_stock_changes, + name="list_stock_changes", + ), + path( + "stock-change-details/", + stock_change_views.list_stock_change_details, + name="list_stock_change_details", + ), + path( + "stock-change/", + stock_change_views.create_full_stock_change, + name="create_full_stock_change", + ), + path( + "stock-change/relaxed/", + stock_change_views.create_relaxed_stock_change, + name="create_relaxed_stock_change", + ), + path( + "stock-change/restrict/", + stock_change_views.create_restrict_stock_change, + name="create_restrict_stock_change", + ), + path( + "stock-change/transfer/", + stock_change_views.transfer_stock_change, + name="stock_transfer", + ), + path( + "stock-change//", + stock_change_views.get_stock_change, + name="get_stock_change", + ), + path( + "stock-change//finish/", + stock_change_views.finish_stock_change, + name="finish_stock_change", + ), + path( + "stock-change//offset/", + stock_change_views.offset_stock_change, + name="offset_stock_change", + ), + path( + "set-merchant-auto-complete-stock-change/", + stock_change_views.set_merchant_auto_complete_stock_change, + name="set_merchant_auto_complete_stock_change", ), - # 用户信息 API - path('user-info/', user_info.user_info, name='user_info'), - + path("user-info/", user_info.user_info, name="user_info"), # 用户创建 API - path('users/create/', CreateUserWithProfileView.as_view(), name='create_user_with_profile'), - + path( + "users/create/", + CreateUserWithProfileView.as_view(), + name="create_user_with_profile", + ), # 库存查询 API - path('inventory/', inventory.InventoryAPIView.as_view(), name='inventory'), - path('purchase-orders/', purchase_views.PurchaseOrderView.as_view(), name='purchase_orders'), - path('purchase-orders//', purchase_views.PurchaseOrderDetailView.as_view(), name='purchase_order_detail'), - path('purchase-orders//review/', purchase_views.PurchaseOrderReviewView.as_view(), name='purchase_order_review'), + path("inventory/", inventory.InventoryAPIView.as_view(), name="inventory"), path( - 'purchase-orders//bind-stock-change/', + "purchase-orders/", + purchase_views.PurchaseOrderView.as_view(), + name="purchase_orders", + ), + path( + "purchase-orders//", + purchase_views.PurchaseOrderDetailView.as_view(), + name="purchase_order_detail", + ), + path( + "purchase-orders//review/", + purchase_views.PurchaseOrderReviewView.as_view(), + name="purchase_order_review", + ), + path( + "purchase-orders//bind-stock-change/", purchase_views.PurchaseOrderBindStockChangeView.as_view(), - name='purchase_order_bind_stock_change', + name="purchase_order_bind_stock_change", ), - path('purchase-return-orders/', purchase_return_views.PurchaseReturnOrderView.as_view(), name='purchase_return_orders'), - path('purchase-return-orders//', purchase_return_views.PurchaseReturnOrderDetailView.as_view(), name='purchase_return_order_detail'), - path('purchase-return-orders//review/', purchase_return_views.PurchaseReturnOrderReviewView.as_view(), name='purchase_return_order_review'), - path('sales-orders/', sales_views.SalesOrderView.as_view(), name='sales_orders'), - path('sales-orders//', sales_views.SalesOrderDetailView.as_view(), name='sales_order_detail'), - path('sales-orders//review/', sales_views.SalesOrderReviewView.as_view(), name='sales_order_review'), - path('sales-return-orders/', sales_return_views.SalesReturnOrderView.as_view(), name='sales_return_orders'), - path('sales-return-orders//', sales_return_views.SalesReturnOrderDetailView.as_view(), name='sales_return_order_detail'), - path('sales-return-orders//review/', sales_return_views.SalesReturnOrderReviewView.as_view(), name='sales_return_order_review'), - path('pre-sales-orders/', pre_sales_views.PreSalesOrderView.as_view(), name='pre_sales_orders'), - path('pre-sales-orders//', pre_sales_views.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'), path( - 'pre-sales-orders//convert-to-sales/', + "purchase-return-orders/", + purchase_return_views.PurchaseReturnOrderView.as_view(), + name="purchase_return_orders", + ), + path( + "purchase-return-orders//", + purchase_return_views.PurchaseReturnOrderDetailView.as_view(), + name="purchase_return_order_detail", + ), + path( + "purchase-return-orders//review/", + purchase_return_views.PurchaseReturnOrderReviewView.as_view(), + name="purchase_return_order_review", + ), + path("sales-orders/", sales_views.SalesOrderView.as_view(), name="sales_orders"), + path( + "sales-orders//", + sales_views.SalesOrderDetailView.as_view(), + name="sales_order_detail", + ), + path( + "sales-orders//review/", + sales_views.SalesOrderReviewView.as_view(), + name="sales_order_review", + ), + path( + "sales-return-orders/", + sales_return_views.SalesReturnOrderView.as_view(), + name="sales_return_orders", + ), + path( + "sales-return-orders//", + sales_return_views.SalesReturnOrderDetailView.as_view(), + name="sales_return_order_detail", + ), + path( + "sales-return-orders//review/", + sales_return_views.SalesReturnOrderReviewView.as_view(), + name="sales_return_order_review", + ), + path( + "pre-sales-orders/", + pre_sales_views.PreSalesOrderView.as_view(), + name="pre_sales_orders", + ), + path( + "pre-sales-orders//", + pre_sales_views.PreSalesOrderDetailView.as_view(), + name="pre_sales_order_detail", + ), + path( + "pre-sales-orders//convert-to-sales/", pre_sales_views.PreSalesOrderConvertToSalesOrderView.as_view(), - name='pre_sales_order_convert_to_sales', + name="pre_sales_order_convert_to_sales", ), path( - 'pre-sales-order-items//allocations/', + "pre-sales-order-items//allocations/", pre_sales_views.PreSalesOrderItemAllocationView.as_view(), - name='pre_sales_order_item_allocations', + name="pre_sales_order_item_allocations", ), path( - 'pre-sales-order-items//allocations//', + "pre-sales-order-items//allocations//", pre_sales_views.PreSalesOrderItemAllocationDetailView.as_view(), - name='pre_sales_order_item_allocation_detail', + name="pre_sales_order_item_allocation_detail", + ), + path( + "pre-purchase-orders/", + pre_purchase_views.PrePurchaseOrderView.as_view(), + name="pre_purchase_orders", + ), + path( + "pre-purchase-orders//", + pre_purchase_views.PrePurchaseOrderDetailView.as_view(), + name="pre_purchase_order_detail", + ), + path( + "payment-orders/", + payment_views.PaymentOrderView.as_view(), + name="payment_orders", + ), + path( + "payment-orders//review/", + payment_views.PaymentOrderReviewView.as_view(), + name="payment_order_review", + ), + path( + "receipt-orders/", + receipt_views.ReceiptOrderView.as_view(), + name="receipt_orders", + ), + path( + "receipt-orders//review/", + receipt_views.ReceiptOrderReviewView.as_view(), + name="receipt_order_review", + ), + path( + "customers//balance/", + balance_views.CustomerBalanceView.as_view(), + name="customer_balance", + ), + path( + "suppliers//balance/", + balance_views.SupplierBalanceView.as_view(), + name="supplier_balance", + ), + path( + "customers//statements/", + statement_views.CustomerStatementView.as_view(), + name="customer_statements", + ), + path( + "suppliers//statements/", + statement_views.SupplierStatementView.as_view(), + name="supplier_statements", + ), + path( + "statements/record/", + statement_views.StatementRecordView.as_view(), + name="statement_record", + ), + path("health/", healthy.HealthCheckView.as_view(), name="health_check"), + path( + "print-count/delta/", print_count.adjust_print_count, name="print_count_delta" ), - path('pre-purchase-orders/', pre_purchase_views.PrePurchaseOrderView.as_view(), name='pre_purchase_orders'), - path('pre-purchase-orders//', pre_purchase_views.PrePurchaseOrderDetailView.as_view(), name='pre_purchase_order_detail'), - path('payment-orders/', payment_views.PaymentOrderView.as_view(), name='payment_orders'), - path('payment-orders//review/', payment_views.PaymentOrderReviewView.as_view(), name='payment_order_review'), - path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'), - path('receipt-orders//review/', receipt_views.ReceiptOrderReviewView.as_view(), name='receipt_order_review'), - path('customers//balance/', balance_views.CustomerBalanceView.as_view(), name='customer_balance'), - path('suppliers//balance/', balance_views.SupplierBalanceView.as_view(), name='supplier_balance'), - path('customers//statements/', statement_views.CustomerStatementView.as_view(), name='customer_statements'), - path('suppliers//statements/', statement_views.SupplierStatementView.as_view(), name='supplier_statements'), - path('statements/record/', statement_views.StatementRecordView.as_view(), name='statement_record'), - path('health/', healthy.HealthCheckView.as_view(), name='health_check'), - path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'), - # 产品图片上传 API - path('products//image/', product_image.ProductImageUploadView.as_view(), name='product_image_upload'), - + path( + "products//image/", + product_image.ProductImageUploadView.as_view(), + name="product_image_upload", + ), # Tencent Cloud (TIIA) API - path('tiia/search-image/', tiia.TiiaSearchImageView.as_view(), name='tiia_search_image'), - + path( + "tiia/search-image/", + tiia.TiiaSearchImageView.as_view(), + name="tiia_search_image", + ), # Stateflow API (使用 Router) - path('stateflow/', include(stateflow_router.urls)), - + path("stateflow/", include(stateflow_router.urls)), # Shipment API path( - 'shipment/shipments/', + "shipment/shipments/", ShipmentListCreateView.as_view(), - name='shipment_list_create' + name="shipment_list_create", ), path( - 'shipment/shipments//', + "shipment/shipments//", ShipmentDetailView.as_view(), - name='shipment_detail' + name="shipment_detail", ), path( - 'shipment/shipments/external/', + "shipment/shipments/external/", ShipmentExternalCreateView.as_view(), - name='shipment_create_external' + name="shipment_create_external", ), path( - 'shipment/sales-items/by-printing-order//', + "shipment/sales-items/by-printing-order//", SalesItemByPrintingOrderView.as_view(), - name='sales_items_by_printing_order' + name="sales_items_by_printing_order", + ), + path( + "shipment/sales-items/", SalesItemCreateView.as_view(), name="sales_item_create" ), - # Settlement API path( - 'settlement/plate-orders/summary/', + "settlement/plate-orders/summary/", PlateOrderSummaryView.as_view(), - name='plate_order_summary' + name="plate_order_summary", ), - # 主 Router (printing-orders 等) - path('', include(main_router.urls)), + path("", include(main_router.urls)), ] diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index ef3b826..17935f5 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -1,6 +1,7 @@ """ Shipment API 序列化器 """ + from rest_framework import serializers from shipment.models import ExternalFinishedProduct, SalesItem, Shipment @@ -10,49 +11,66 @@ class ShipmentSerializer(serializers.ModelSerializer): """ 出货单序列化器(只读,用于返回数据) """ - customer_name = serializers.CharField(source='customer.name', read_only=True) - created_by_id = serializers.IntegerField(source='created_by.id', read_only=True, allow_null=True) + + customer_name = serializers.CharField(source="customer.name", read_only=True) + created_by_id = serializers.IntegerField( + source="created_by.id", read_only=True, allow_null=True + ) created_by_name = serializers.SerializerMethodField() items_count = serializers.SerializerMethodField() - cancelled_by_id = serializers.IntegerField(source='cancelled_by.id', read_only=True, allow_null=True) + cancelled_by_id = serializers.IntegerField( + source="cancelled_by.id", read_only=True, allow_null=True + ) cancelled_by_name = serializers.SerializerMethodField() - status_display = serializers.CharField(source='get_status_display', read_only=True) + status_display = serializers.CharField(source="get_status_display", read_only=True) external_finished_products_count = serializers.SerializerMethodField() - merchant_id = serializers.IntegerField(source='merchant.id', read_only=True) - merchant_name = serializers.CharField(source='merchant.name', read_only=True) + merchant_id = serializers.IntegerField(source="merchant.id", read_only=True) + merchant_name = serializers.CharField(source="merchant.name", read_only=True) sales_items = serializers.SerializerMethodField() external_finished_products = serializers.SerializerMethodField() - + class Meta: model = Shipment fields = [ - 'id', 'merchant_id', 'merchant_name', - 'customer', 'customer_name', 'shipment_date', 'area', 'remark', - 'status', 'status_display', - 'external_id', - 'cancelled_at', 'cancelled_by_id', 'cancelled_by_name', - 'items_count', 'created_by_id', 'created_by_name', - 'external_finished_products_count', - 'sales_items', - 'external_finished_products', - 'created_at', 'updated_at' + "id", + "merchant_id", + "merchant_name", + "customer", + "customer_name", + "shipment_date", + "area", + "remark", + "status", + "status_display", + "external_id", + "cancelled_at", + "cancelled_by_id", + "cancelled_by_name", + "items_count", + "created_by_id", + "created_by_name", + "external_finished_products_count", + "sales_items", + "external_finished_products", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] - + read_only_fields = ["id", "created_at", "updated_at"] + def get_created_by_name(self, obj): if obj.created_by: - employee = getattr(obj.created_by, 'employee', None) + employee = getattr(obj.created_by, "employee", None) if employee: return employee.name return obj.created_by.username return None - + def get_items_count(self, obj): return obj.items.count() def get_cancelled_by_name(self, obj): if obj.cancelled_by: - employee = getattr(obj.cancelled_by, 'employee', None) + employee = getattr(obj.cancelled_by, "employee", None) if employee: return employee.name return obj.cancelled_by.username @@ -66,7 +84,7 @@ class ShipmentSerializer(serializers.ModelSerializer): 出货单关联的销售品明细(无则返回空数组)。 """ # 优先使用 prefetch 的 related manager;兜底为 none() - rel = getattr(obj, 'items', None) + rel = getattr(obj, "items", None) items = list(rel.all()) if rel is not None else [] return SalesItemSerializer(items, many=True).data @@ -74,7 +92,7 @@ class ShipmentSerializer(serializers.ModelSerializer): """ 出货单关联的外部成品表明细(无则返回空数组)。 """ - rel = getattr(obj, 'external_finished_products', None) + rel = getattr(obj, "external_finished_products", None) products = list(rel.all()) if rel is not None else [] return ExternalFinishedProductSerializer(products, many=True).data @@ -83,25 +101,28 @@ class ExternalFinishedProductSerializer(serializers.ModelSerializer): """ 外部成品表序列化器(只读) """ - created_by_id = serializers.IntegerField(source='created_by.id', read_only=True, allow_null=True) + + created_by_id = serializers.IntegerField( + source="created_by.id", read_only=True, allow_null=True + ) created_by_name = serializers.SerializerMethodField() class Meta: model = ExternalFinishedProduct fields = [ - 'id', - 'style_name', - 'num_of_rolls', - 'remark', - 'created_at', - 'created_by_id', - 'created_by_name', + "id", + "style_name", + "num_of_rolls", + "remark", + "created_at", + "created_by_id", + "created_by_name", ] read_only_fields = fields def get_created_by_name(self, obj): if obj.created_by: - employee = getattr(obj.created_by, 'employee', None) + employee = getattr(obj.created_by, "employee", None) if employee: return employee.name return obj.created_by.username @@ -112,57 +133,79 @@ class ShipmentCreateNormalSerializer(serializers.Serializer): """ 出货单创建序列化器(普通版) """ - customer = serializers.IntegerField(help_text='客户ID') - shipment_date = serializers.DateField(help_text='出货日期') - area = serializers.CharField(max_length=30, required=False, default='', allow_blank=True, help_text='出货地区(可选)') - remark = serializers.CharField(required=False, default='', allow_blank=True, help_text='备注') + + customer = serializers.IntegerField(help_text="客户ID") + shipment_date = serializers.DateField(help_text="出货日期") + area = serializers.CharField( + max_length=30, + required=False, + default="", + allow_blank=True, + help_text="出货地区(可选)", + ) + remark = serializers.CharField( + required=False, default="", allow_blank=True, help_text="备注" + ) sales_items = serializers.ListField( child=serializers.IntegerField(), required=False, default=list, - help_text='要关联的销售品ID列表' + help_text="要关联的销售品ID列表", ) - + def validate_sales_items(self, value): # 去重 return list(set(value)) if value else [] + class ExternalFinishedProductInputSerializer(serializers.Serializer): """外部成品表写入结构(external create 专用)""" + style_name = serializers.CharField(max_length=200) num_of_rolls = serializers.IntegerField(min_value=0) - remark = serializers.CharField(max_length=200, required=False, allow_blank=True, allow_null=True, default='') + remark = serializers.CharField( + max_length=200, required=False, allow_blank=True, allow_null=True, default="" + ) class ShipmentCreateExternalSerializer(serializers.Serializer): """ 出货单创建序列化器(external 版) - + 特点: - 不绑定任何 SalesItem - 必须提供 external_id - 同时写入 ExternalFinishedProduct 列表并关联到 Shipment """ - customer = serializers.IntegerField(help_text='客户ID') - shipment_date = serializers.DateField(help_text='出货日期') - area = serializers.CharField(max_length=30, required=False, default='', allow_blank=True, help_text='出货地区(可选)') - remark = serializers.CharField(required=False, default='', allow_blank=True, help_text='备注') - external_id = serializers.CharField(max_length=120, help_text='外部订单号(必填)') + + customer = serializers.IntegerField(help_text="客户ID") + shipment_date = serializers.DateField(help_text="出货日期") + area = serializers.CharField( + max_length=30, + required=False, + default="", + allow_blank=True, + help_text="出货地区(可选)", + ) + remark = serializers.CharField( + required=False, default="", allow_blank=True, help_text="备注" + ) + external_id = serializers.CharField(max_length=120, help_text="外部订单号(必填)") external_finished_products = serializers.ListField( child=ExternalFinishedProductInputSerializer(), required=True, - help_text='外部成品表结构数组(必填)' + help_text="外部成品表结构数组(必填)", ) def validate_external_id(self, value): - value = (value or '').strip() + value = (value or "").strip() if not value: - raise serializers.ValidationError('external_id 不能为空') + raise serializers.ValidationError("external_id 不能为空") return value def validate_external_finished_products(self, value): if not value: - raise serializers.ValidationError('external_finished_products 不能为空') + raise serializers.ValidationError("external_finished_products 不能为空") return value @@ -172,19 +215,31 @@ class ShipmentUpdateSerializer(serializers.Serializer): 说明:本次新增字段 area,需要保证更新入口可写入并回显。 """ - customer = serializers.IntegerField(required=False, help_text='客户ID(可选)') - shipment_date = serializers.DateField(required=False, help_text='出货日期(可选)') - area = serializers.CharField(max_length=30, required=False, allow_blank=True, help_text='出货地区(可选)') - remark = serializers.CharField(required=False, allow_blank=True, help_text='备注(可选)') - external_id = serializers.CharField(max_length=120, required=False, allow_blank=True, allow_null=True, help_text='外部订单号(可选)') + + customer = serializers.IntegerField(required=False, help_text="客户ID(可选)") + shipment_date = serializers.DateField(required=False, help_text="出货日期(可选)") + area = serializers.CharField( + max_length=30, required=False, allow_blank=True, help_text="出货地区(可选)" + ) + remark = serializers.CharField( + required=False, allow_blank=True, help_text="备注(可选)" + ) + external_id = serializers.CharField( + max_length=120, + required=False, + allow_blank=True, + allow_null=True, + help_text="外部订单号(可选)", + ) class SalesItemSerializer(serializers.Serializer): """ 销售品序列化器(只读) - + 用于返回销售品数据 """ + id = serializers.IntegerField(read_only=True) name = serializers.CharField(read_only=True) quantity = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True) @@ -194,19 +249,92 @@ class SalesItemSerializer(serializers.Serializer): remark = serializers.CharField(read_only=True) printing_job_id = serializers.IntegerField(read_only=True) customer_id = serializers.IntegerField(read_only=True) - shipment_id = serializers.IntegerField(source='shipment.id', read_only=True, allow_null=True) - shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True, allow_null=True) + shipment_id = serializers.IntegerField( + source="shipment.id", read_only=True, allow_null=True + ) + shipment_date = serializers.DateField( + source="shipment.shipment_date", read_only=True, allow_null=True + ) created_at = serializers.DateTimeField(read_only=True) - created_by_id = serializers.IntegerField(source='created_by.id', read_only=True, allow_null=True) + created_by_id = serializers.IntegerField( + source="created_by.id", read_only=True, allow_null=True + ) created_by_name = serializers.SerializerMethodField() - + def get_unit_display(self, obj): return obj.get_unit_display() - + def get_created_by_name(self, obj): if obj.created_by: - employee = getattr(obj.created_by, 'employee', None) + employee = getattr(obj.created_by, "employee", None) if employee: return employee.name return obj.created_by.username return None + + +class SalesItemCreateSerializer(serializers.Serializer): + """ + 销售品创建序列化器 + + 用于手动创建销售品(当自动转化开关关闭时使用) + """ + + printing_job_id = serializers.IntegerField( + required=True, min_value=1, help_text="生产任务ID(必填)" + ) + name = serializers.CharField( + max_length=200, required=True, help_text="销售品名称(必填)" + ) + quantity = serializers.CharField( + max_length=20, required=True, help_text="数量(必填,支持小数,如:100.50)" + ) + unit = serializers.IntegerField( + required=True, help_text="单位(必填):1=米, 2=件, 3=码, 4=个" + ) + customer_id = serializers.IntegerField( + required=False, allow_null=True, help_text="客户ID(可选,默认从生产订单获取)" + ) + remark = serializers.CharField( + max_length=200, + required=False, + allow_blank=True, + default="", + help_text="备注(可选)", + ) + position = serializers.CharField( + max_length=200, + required=False, + allow_blank=True, + default="", + help_text="货位(可选)", + ) + + def validate_unit(self, value): + """验证单位值是否在允许范围内""" + from shipment.models import UnitChoices + + valid_units = [choice[0] for choice in UnitChoices.choices] + if value not in valid_units: + raise serializers.ValidationError( + f"单位值无效。可选值:{dict(UnitChoices.choices)}" + ) + return value + + def create(self, validated_data): + """创建销售品""" + from shipment.services import create_sales_item + + # 获取当前用户(从context传入) + created_by = self.context["request"].user + + return create_sales_item( + printing_job_id=validated_data["printing_job_id"], + name=validated_data["name"], + quantity=validated_data["quantity"], + unit=validated_data["unit"], + created_by=created_by, + customer_id=validated_data.get("customer_id"), + remark=validated_data.get("remark", ""), + position=validated_data.get("position", ""), + ) diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index f4977c5..b80b703 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -1,6 +1,7 @@ """ Shipment API 测试 """ + from decimal import Decimal from django.test import TestCase @@ -19,423 +20,427 @@ User = get_user_model() class SalesItemByPrintingOrderAPITestCase(TestCase): """测试通过生产订单查询销售品 API""" - + def setUp(self): self.client = APIClient() - + # 创建商户 self.merchant = basic_models.Merchant.objects.create( - name='测试印花厂', - type=basic_models.MerchantTypeEnum.FACTORY + name="测试印花厂", type=basic_models.MerchantTypeEnum.FACTORY ) - + # 创建用户 self.user = User.objects.create_user( - username='testuser', - password='testpass123', - email='test@example.com' + username="testuser", password="testpass123", email="test@example.com" ) - + # 创建员工并关联商户 self.employee = basic_models.Employee.objects.create( sys_user=self.user, merchant=self.merchant, - name='测试员工', - mobile='13800138000', - status=basic_models.EmployeeStatusEnum.ACTIVE + name="测试员工", + mobile="13800138000", + status=basic_models.EmployeeStatusEnum.ACTIVE, ) - + # 创建客户 self.customer = basic_models.Customer.objects.create( merchant=self.merchant, - name='测试客户', - mobile='13900139000', - area='测试地区' + name="测试客户", + mobile="13900139000", + area="测试地区", ) - + # 创建流程 - self.state1 = stateflow_models.State.objects.create(name='待印染') - self.state2 = stateflow_models.State.objects.create(name='印染中') - self.state3 = stateflow_models.State.objects.create(name='已完成') - - self.process = stateflow_models.Process.objects.create(name='印染流程') + self.state1 = stateflow_models.State.objects.create(name="待印染") + self.state2 = stateflow_models.State.objects.create(name="印染中") + self.state3 = stateflow_models.State.objects.create(name="已完成") + + self.process = stateflow_models.Process.objects.create(name="印染流程") self.process.replace_nodes([self.state1, self.state2, self.state3]) - + # 创建产品分类 self.category = basic_models.ProductCategory.objects.create( merchant=self.merchant, - name='测试分类', + name="测试分类", ) - + # 创建产品 self.product = basic_models.Product.objects.create( merchant=self.merchant, category=self.category, - name='测试产品', - human_id='TEST001', + name="测试产品", + human_id="TEST001", ) - + # 创建印染订单 self.printing_order = printing_models.PrintingOrder.objects.create( merchant=self.merchant, customer=self.customer, - fabric='测试面料', - width='150cm', + fabric="测试面料", + width="150cm", process=self.process, created_by=self.user, ) - + # 创建印染任务 self.printing_job1 = printing_models.PrintingJob.objects.create( merchant=self.merchant, printing_order=self.printing_order, product=self.product, quantity=100, - unit='米', + unit="米", created_by=self.user, ) - + self.printing_job2 = printing_models.PrintingJob.objects.create( merchant=self.merchant, printing_order=self.printing_order, product=self.product, quantity=200, - unit='米', + unit="米", created_by=self.user, ) - + # 创建出货单 self.shipment = shipment_models.Shipment.objects.create( merchant=self.merchant, customer=self.customer, - shipment_date='2026-01-14', + shipment_date="2026-01-14", created_by=self.user, ) - + # 创建销售品 - 未关联出货单 self.sales_item1 = shipment_models.SalesItem.objects.create( merchant=self.merchant, - name='销售品1', - quantity=Decimal('50.00'), + name="销售品1", + quantity=Decimal("50.00"), unit=shipment_models.UnitChoices.METER, printing_job_id=self.printing_job1.id, created_by=self.user, ) - + self.sales_item2 = shipment_models.SalesItem.objects.create( merchant=self.merchant, - name='销售品2', - quantity=Decimal('30.00'), + name="销售品2", + quantity=Decimal("30.00"), unit=shipment_models.UnitChoices.METER, printing_job_id=self.printing_job1.id, - position='A1-01', - remark='备注信息', + position="A1-01", + remark="备注信息", created_by=self.user, ) - + # 创建销售品 - 已关联出货单 self.sales_item3 = shipment_models.SalesItem.objects.create( merchant=self.merchant, - name='销售品3(已出货)', - quantity=Decimal('100.00'), + name="销售品3(已出货)", + quantity=Decimal("100.00"), unit=shipment_models.UnitChoices.METER, printing_job_id=self.printing_job2.id, shipment=self.shipment, created_by=self.user, ) - + # 创建与该订单无关的销售品 self.sales_item_other = shipment_models.SalesItem.objects.create( merchant=self.merchant, - name='其它销售品', - quantity=Decimal('999.00'), + name="其它销售品", + quantity=Decimal("999.00"), unit=shipment_models.UnitChoices.PIECE, printing_job_id=99999, # 不存在的 job created_by=self.user, ) - + # 认证用户 self.client.force_authenticate(user=self.user) - + def test_get_sales_items_by_printing_order_exclude_shipped(self): """测试查询销售品 - 默认不包含已出货的""" - url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/' + url = ( + f"/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/" + ) response = self.client.get(url) - + self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - + # 应该只返回2个未出货的销售品 - self.assertEqual(data['count'], 2) - + self.assertEqual(data["count"], 2) + # 检查返回的销售品 - item_ids = [item['id'] for item in data['results']] + item_ids = [item["id"] for item in data["results"]] self.assertIn(self.sales_item1.id, item_ids) self.assertIn(self.sales_item2.id, item_ids) self.assertNotIn(self.sales_item3.id, item_ids) # 已出货的不应该在列表中 self.assertNotIn(self.sales_item_other.id, item_ids) # 其它订单的也不在 - + def test_get_sales_items_by_printing_order_include_shipped(self): """测试查询销售品 - 包含已出货的""" - url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/?include_already_has_shipment=true' + url = f"/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/?include_already_has_shipment=true" response = self.client.get(url) - + self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - + # 应该返回3个销售品(包含已出货的) - self.assertEqual(data['count'], 3) - + self.assertEqual(data["count"], 3) + # 检查返回的销售品 - item_ids = [item['id'] for item in data['results']] + item_ids = [item["id"] for item in data["results"]] self.assertIn(self.sales_item1.id, item_ids) self.assertIn(self.sales_item2.id, item_ids) self.assertIn(self.sales_item3.id, item_ids) # 已出货的也应该在列表中 self.assertNotIn(self.sales_item_other.id, item_ids) # 其它订单的依然不在 - + def test_get_sales_items_response_format(self): """测试返回数据格式""" - url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/' + url = ( + f"/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/" + ) response = self.client.get(url) - + self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - + # 找到 sales_item2(包含 position 和 remark) - item = next(item for item in data['results'] if item['id'] == self.sales_item2.id) - + item = next( + item for item in data["results"] if item["id"] == self.sales_item2.id + ) + # 检查所有字段 - self.assertEqual(item['name'], '销售品2') - self.assertEqual(Decimal(item['quantity']), Decimal('30.00')) - self.assertEqual(item['unit'], shipment_models.UnitChoices.METER) - self.assertEqual(item['unit_display'], '米') - self.assertEqual(item['position'], 'A1-01') - self.assertEqual(item['remark'], '备注信息') - self.assertEqual(item['printing_job_id'], self.printing_job1.id) - self.assertIsNone(item['shipment_id']) - self.assertIsNone(item['shipment_date']) - self.assertIsNotNone(item['created_at']) - self.assertEqual(item['created_by_id'], self.user.id) - + self.assertEqual(item["name"], "销售品2") + self.assertEqual(Decimal(item["quantity"]), Decimal("30.00")) + self.assertEqual(item["unit"], shipment_models.UnitChoices.METER) + self.assertEqual(item["unit_display"], "米") + self.assertEqual(item["position"], "A1-01") + self.assertEqual(item["remark"], "备注信息") + self.assertEqual(item["printing_job_id"], self.printing_job1.id) + self.assertIsNone(item["shipment_id"]) + self.assertIsNone(item["shipment_date"]) + self.assertIsNotNone(item["created_at"]) + self.assertEqual(item["created_by_id"], self.user.id) + def test_get_sales_items_shipped_item_format(self): """测试已出货的销售品返回格式""" - url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/?include_already_has_shipment=true' + url = f"/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/?include_already_has_shipment=true" response = self.client.get(url) - + self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - + # 找到已出货的销售品 - item = next(item for item in data['results'] if item['id'] == self.sales_item3.id) - + item = next( + item for item in data["results"] if item["id"] == self.sales_item3.id + ) + # 检查出货单信息 - self.assertEqual(item['shipment_id'], self.shipment.id) - self.assertEqual(item['shipment_date'], '2026-01-14') - + self.assertEqual(item["shipment_id"], self.shipment.id) + self.assertEqual(item["shipment_date"], "2026-01-14") + def test_get_sales_items_printing_order_not_found(self): """测试生产订单不存在""" - url = '/api/v1/shipment/sales-items/by-printing-order/99999/' + url = "/api/v1/shipment/sales-items/by-printing-order/99999/" response = self.client.get(url) - + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertIn('不存在', response.json()['detail']) - + self.assertIn("不存在", response.json()["detail"]) + def test_get_sales_items_empty_result(self): """测试生产订单没有关联销售品""" # 创建一个没有销售品的订单 empty_order = printing_models.PrintingOrder.objects.create( merchant=self.merchant, customer=self.customer, - fabric='测试面料2', - width='150cm', + fabric="测试面料2", + width="150cm", process=self.process, created_by=self.user, ) - - url = f'/api/v1/shipment/sales-items/by-printing-order/{empty_order.id}/' + + url = f"/api/v1/shipment/sales-items/by-printing-order/{empty_order.id}/" response = self.client.get(url) - + self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - - self.assertEqual(data['count'], 0) - self.assertEqual(data['results'], []) - + + self.assertEqual(data["count"], 0) + self.assertEqual(data["results"], []) + def test_get_sales_items_unauthenticated(self): """测试未认证用户""" self.client.logout() - - url = f'/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/' + + url = ( + f"/api/v1/shipment/sales-items/by-printing-order/{self.printing_order.id}/" + ) response = self.client.get(url) - + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) class ShipmentCreateAPITestCase(TestCase): """测试创建出货单 API""" - + def setUp(self): self.client = APIClient() - + # 创建商户 self.merchant = basic_models.Merchant.objects.create( - name='测试印花厂', - type=basic_models.MerchantTypeEnum.FACTORY + name="测试印花厂", type=basic_models.MerchantTypeEnum.FACTORY ) - + # 创建用户 self.user = User.objects.create_user( - username='testuser', - password='testpass123', - email='test@example.com' + username="testuser", password="testpass123", email="test@example.com" ) - + # 创建员工并关联商户 self.employee = basic_models.Employee.objects.create( sys_user=self.user, merchant=self.merchant, - name='测试员工', - mobile='13800138000', - status=basic_models.EmployeeStatusEnum.ACTIVE + name="测试员工", + mobile="13800138000", + status=basic_models.EmployeeStatusEnum.ACTIVE, ) - + # 创建客户 self.customer = basic_models.Customer.objects.create( merchant=self.merchant, - name='测试客户', - mobile='13900139000', - area='测试地区' + name="测试客户", + mobile="13900139000", + area="测试地区", ) - + # 创建销售品(未关联出货单) self.sales_item1 = shipment_models.SalesItem.objects.create( merchant=self.merchant, - name='销售品1', - quantity=Decimal('50.00'), + name="销售品1", + quantity=Decimal("50.00"), unit=shipment_models.UnitChoices.METER, created_by=self.user, ) - + self.sales_item2 = shipment_models.SalesItem.objects.create( merchant=self.merchant, - name='销售品2', - quantity=Decimal('30.00'), + name="销售品2", + quantity=Decimal("30.00"), unit=shipment_models.UnitChoices.METER, created_by=self.user, ) - + # 创建已关联出货单的销售品 self.existing_shipment = shipment_models.Shipment.objects.create( merchant=self.merchant, customer=self.customer, - shipment_date='2026-01-13', + shipment_date="2026-01-13", created_by=self.user, ) self.sales_item_shipped = shipment_models.SalesItem.objects.create( merchant=self.merchant, - name='销售品3(已出货)', - quantity=Decimal('100.00'), + name="销售品3(已出货)", + quantity=Decimal("100.00"), unit=shipment_models.UnitChoices.METER, shipment=self.existing_shipment, created_by=self.user, ) - + # 认证用户 self.client.force_authenticate(user=self.user) - + def test_create_shipment_success(self): """测试成功创建出货单""" data = { - 'customer': self.customer.id, - 'shipment_date': '2026-01-14', - 'area': '华东', - 'remark': '测试备注', - 'sales_items': [self.sales_item1.id, self.sales_item2.id] + "customer": self.customer.id, + "shipment_date": "2026-01-14", + "area": "华东", + "remark": "测试备注", + "sales_items": [self.sales_item1.id, self.sales_item2.id], } - - response = self.client.post('/api/v1/shipment/shipments/', data, format='json') - + + response = self.client.post("/api/v1/shipment/shipments/", data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) result = response.json() - + # 验证返回数据 - self.assertIn('id', result) - self.assertEqual(result['customer'], self.customer.id) - self.assertEqual(result['customer_name'], self.customer.name) - self.assertEqual(result['shipment_date'], '2026-01-14') - self.assertEqual(result.get('area', ''), '华东') - self.assertEqual(result['remark'], '测试备注') - self.assertEqual(result['items_count'], 2) - self.assertEqual(result['created_by_id'], self.user.id) - + self.assertIn("id", result) + self.assertEqual(result["customer"], self.customer.id) + self.assertEqual(result["customer_name"], self.customer.name) + self.assertEqual(result["shipment_date"], "2026-01-14") + self.assertEqual(result.get("area", ""), "华东") + self.assertEqual(result["remark"], "测试备注") + self.assertEqual(result["items_count"], 2) + self.assertEqual(result["created_by_id"], self.user.id) + # 验证销售品已关联到出货单 self.sales_item1.refresh_from_db() self.sales_item2.refresh_from_db() - self.assertEqual(self.sales_item1.shipment_id, result['id']) - self.assertEqual(self.sales_item2.shipment_id, result['id']) - + self.assertEqual(self.sales_item1.shipment_id, result["id"]) + self.assertEqual(self.sales_item2.shipment_id, result["id"]) + def test_create_shipment_without_sales_items(self): """测试创建出货单但不关联销售品""" data = { - 'customer': self.customer.id, - 'shipment_date': '2026-01-14', + "customer": self.customer.id, + "shipment_date": "2026-01-14", } - - response = self.client.post('/api/v1/shipment/shipments/', data, format='json') - + + response = self.client.post("/api/v1/shipment/shipments/", data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) result = response.json() - self.assertEqual(result['items_count'], 0) - + self.assertEqual(result["items_count"], 0) + def test_create_shipment_customer_not_found(self): """测试客户不存在""" data = { - 'customer': 99999, - 'shipment_date': '2026-01-14', + "customer": 99999, + "shipment_date": "2026-01-14", } - - response = self.client.post('/api/v1/shipment/shipments/', data, format='json') - + + response = self.client.post("/api/v1/shipment/shipments/", data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('不存在', response.json()['detail']) - + self.assertIn("不存在", response.json()["detail"]) + def test_create_shipment_sales_item_not_found(self): """测试销售品不存在""" data = { - 'customer': self.customer.id, - 'shipment_date': '2026-01-14', - 'sales_items': [99999] + "customer": self.customer.id, + "shipment_date": "2026-01-14", + "sales_items": [99999], } - - response = self.client.post('/api/v1/shipment/shipments/', data, format='json') - + + response = self.client.post("/api/v1/shipment/shipments/", data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('不存在', response.json()['detail']) - + self.assertIn("不存在", response.json()["detail"]) + def test_create_shipment_sales_item_already_shipped(self): """测试销售品已关联到其他出货单""" data = { - 'customer': self.customer.id, - 'shipment_date': '2026-01-14', - 'sales_items': [self.sales_item_shipped.id] + "customer": self.customer.id, + "shipment_date": "2026-01-14", + "sales_items": [self.sales_item_shipped.id], } - - response = self.client.post('/api/v1/shipment/shipments/', data, format='json') - + + response = self.client.post("/api/v1/shipment/shipments/", data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('已关联', response.json()['detail']) - + self.assertIn("已关联", response.json()["detail"]) + def test_create_shipment_unauthenticated(self): """测试未认证用户""" self.client.logout() - + data = { - 'customer': self.customer.id, - 'shipment_date': '2026-01-14', + "customer": self.customer.id, + "shipment_date": "2026-01-14", } - - response = self.client.post('/api/v1/shipment/shipments/', data, format='json') - + + response = self.client.post("/api/v1/shipment/shipments/", data, format="json") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) @@ -447,39 +452,38 @@ class ShipmentExternalCreateAPITestCase(TestCase): # 创建商户 self.merchant = basic_models.Merchant.objects.create( - name='测试印花厂', - type=basic_models.MerchantTypeEnum.FACTORY + name="测试印花厂", type=basic_models.MerchantTypeEnum.FACTORY ) # 创建用户 self.user = User.objects.create_user( - username='testuser_ext', - password='testpass123', - email='test_ext@example.com' + username="testuser_ext", + password="testpass123", + email="test_ext@example.com", ) # 创建员工并关联商户 self.employee = basic_models.Employee.objects.create( sys_user=self.user, merchant=self.merchant, - name='测试员工Ext', - mobile='13800138002', - status=basic_models.EmployeeStatusEnum.ACTIVE + name="测试员工Ext", + mobile="13800138002", + status=basic_models.EmployeeStatusEnum.ACTIVE, ) # 创建客户(需要同 merchant) self.customer = basic_models.Customer.objects.create( merchant=self.merchant, - name='测试客户Ext', - mobile='13900139002', - area='测试地区Ext' + name="测试客户Ext", + mobile="13900139002", + area="测试地区Ext", ) # 创建一个销售品(用于验证 external 版不会绑定任何销售品) self.sales_item = shipment_models.SalesItem.objects.create( merchant=self.merchant, - name='销售品-不应被绑定', - quantity=Decimal('10.00'), + name="销售品-不应被绑定", + quantity=Decimal("10.00"), unit=shipment_models.UnitChoices.METER, created_by=self.user, ) @@ -488,37 +492,43 @@ class ShipmentExternalCreateAPITestCase(TestCase): def test_create_external_shipment_success(self): data = { - 'customer': self.customer.id, - 'shipment_date': '2026-01-14', - 'area': '华南', - 'remark': 'external 备注', - 'external_id': 'EXT-ORDER-001', - 'external_finished_products': [ - {'style_name': '款式A', 'num_of_rolls': 2, 'remark': 'A备注'}, - {'style_name': '款式B', 'num_of_rolls': 5}, - ] + "customer": self.customer.id, + "shipment_date": "2026-01-14", + "area": "华南", + "remark": "external 备注", + "external_id": "EXT-ORDER-001", + "external_finished_products": [ + {"style_name": "款式A", "num_of_rolls": 2, "remark": "A备注"}, + {"style_name": "款式B", "num_of_rolls": 5}, + ], } - response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') + response = self.client.post( + "/api/v1/shipment/shipments/external/", data, format="json" + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) result = response.json() - self.assertEqual(result['customer'], self.customer.id) - self.assertEqual(result['external_id'], 'EXT-ORDER-001') - self.assertEqual(result.get('area', ''), '华南') - self.assertEqual(result['items_count'], 0) - self.assertEqual(result['external_finished_products_count'], 2) + self.assertEqual(result["customer"], self.customer.id) + self.assertEqual(result["external_id"], "EXT-ORDER-001") + self.assertEqual(result.get("area", ""), "华南") + self.assertEqual(result["items_count"], 0) + self.assertEqual(result["external_finished_products_count"], 2) - shipment_id = result['id'] + shipment_id = result["id"] # 验证外部成品表写入并关联 self.assertEqual( - shipment_models.ExternalFinishedProduct.objects.filter(shipment_id=shipment_id).count(), - 2 + shipment_models.ExternalFinishedProduct.objects.filter( + shipment_id=shipment_id + ).count(), + 2, ) - efp_a = shipment_models.ExternalFinishedProduct.objects.filter(shipment_id=shipment_id, style_name='款式A').first() + efp_a = shipment_models.ExternalFinishedProduct.objects.filter( + shipment_id=shipment_id, style_name="款式A" + ).first() self.assertIsNotNone(efp_a) - self.assertEqual(getattr(efp_a, 'remark', None) or '', 'A备注') + self.assertEqual(getattr(efp_a, "remark", None) or "", "A备注") # 验证不会绑定任何销售品 self.sales_item.refresh_from_db() @@ -526,39 +536,45 @@ class ShipmentExternalCreateAPITestCase(TestCase): def test_create_external_shipment_external_id_required(self): data = { - 'customer': self.customer.id, - 'shipment_date': '2026-01-14', - 'external_id': ' ', - 'external_finished_products': [ - {'style_name': '款式A', 'num_of_rolls': 1}, - ] + "customer": self.customer.id, + "shipment_date": "2026-01-14", + "external_id": " ", + "external_finished_products": [ + {"style_name": "款式A", "num_of_rolls": 1}, + ], } - response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') + response = self.client.post( + "/api/v1/shipment/shipments/external/", data, format="json" + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('external_id', response.json()) + self.assertIn("external_id", response.json()) def test_create_external_shipment_products_required(self): data = { - 'customer': self.customer.id, - 'shipment_date': '2026-01-14', - 'external_id': 'EXT-ORDER-002', - 'external_finished_products': [] + "customer": self.customer.id, + "shipment_date": "2026-01-14", + "external_id": "EXT-ORDER-002", + "external_finished_products": [], } - response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') + response = self.client.post( + "/api/v1/shipment/shipments/external/", data, format="json" + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('external_finished_products', response.json()) + self.assertIn("external_finished_products", response.json()) def test_create_external_shipment_unauthenticated(self): self.client.logout() data = { - 'customer': self.customer.id, - 'shipment_date': '2026-01-14', - 'external_id': 'EXT-ORDER-003', - 'external_finished_products': [ - {'style_name': '款式A', 'num_of_rolls': 1}, - ] + "customer": self.customer.id, + "shipment_date": "2026-01-14", + "external_id": "EXT-ORDER-003", + "external_finished_products": [ + {"style_name": "款式A", "num_of_rolls": 1}, + ], } - response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') + response = self.client.post( + "/api/v1/shipment/shipments/external/", data, format="json" + ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) @@ -569,121 +585,109 @@ class ShipmentQueryAPITestCase(TestCase): self.client = APIClient() self.merchant1 = basic_models.Merchant.objects.create( - name='商户1', - type=basic_models.MerchantTypeEnum.FACTORY + name="商户1", type=basic_models.MerchantTypeEnum.FACTORY ) self.merchant2 = basic_models.Merchant.objects.create( - name='商户2', - type=basic_models.MerchantTypeEnum.FACTORY + name="商户2", type=basic_models.MerchantTypeEnum.FACTORY ) self.user1 = User.objects.create_user( - username='u1', - password='pass123', - email='u1@example.com' + username="u1", password="pass123", email="u1@example.com" ) self.emp1 = basic_models.Employee.objects.create( sys_user=self.user1, merchant=self.merchant1, - name='员工1', - mobile='13800138010', - status=basic_models.EmployeeStatusEnum.ACTIVE + name="员工1", + mobile="13800138010", + status=basic_models.EmployeeStatusEnum.ACTIVE, ) self.user2 = User.objects.create_user( - username='u2', - password='pass123', - email='u2@example.com' + username="u2", password="pass123", email="u2@example.com" ) self.emp2 = basic_models.Employee.objects.create( sys_user=self.user2, merchant=self.merchant2, - name='员工2', - mobile='13800138011', - status=basic_models.EmployeeStatusEnum.ACTIVE + name="员工2", + mobile="13800138011", + status=basic_models.EmployeeStatusEnum.ACTIVE, ) self.customer1 = basic_models.Customer.objects.create( - merchant=self.merchant1, - name='客户1', - mobile='13900139010', - area='A' + merchant=self.merchant1, name="客户1", mobile="13900139010", area="A" ) self.customer2 = basic_models.Customer.objects.create( - merchant=self.merchant2, - name='客户2', - mobile='13900139011', - area='B' + merchant=self.merchant2, name="客户2", mobile="13900139011", area="B" ) self.shipment1 = shipment_models.Shipment.objects.create( merchant=self.merchant1, customer=self.customer1, - shipment_date='2026-01-15', + shipment_date="2026-01-15", created_by=self.user1, - remark='s1', - area='A1', + remark="s1", + area="A1", ) self.shipment2 = shipment_models.Shipment.objects.create( merchant=self.merchant2, customer=self.customer2, - shipment_date='2026-01-15', + shipment_date="2026-01-15", created_by=self.user2, - remark='s2', - area='B1', + remark="s2", + area="B1", ) self.client.force_authenticate(user=self.user1) def test_list_shipments_only_current_merchant(self): - resp = self.client.get('/api/v1/shipment/shipments/') + resp = self.client.get("/api/v1/shipment/shipments/") self.assertEqual(resp.status_code, status.HTTP_200_OK) data = resp.json() # LimitOffsetPagination 结构:count/next/previous/results - self.assertIn('count', data) - self.assertIn('results', data) - ids = [it['id'] for it in data['results']] + self.assertIn("count", data) + self.assertIn("results", data) + ids = [it["id"] for it in data["results"]] self.assertIn(self.shipment1.id, ids) self.assertNotIn(self.shipment2.id, ids) # 关键:列表序列化器必须稳定输出明细数组字段(即使为空) - item = next(it for it in data['results'] if it['id'] == self.shipment1.id) - self.assertIn('area', item) - self.assertEqual(item['area'], 'A1') - self.assertIn('sales_items', item) - self.assertIsInstance(item['sales_items'], list) - self.assertIn('external_finished_products', item) - self.assertIsInstance(item['external_finished_products'], list) + item = next(it for it in data["results"] if it["id"] == self.shipment1.id) + self.assertIn("area", item) + self.assertEqual(item["area"], "A1") + self.assertIn("sales_items", item) + self.assertIsInstance(item["sales_items"], list) + self.assertIn("external_finished_products", item) + self.assertIsInstance(item["external_finished_products"], list) def test_retrieve_shipment_success(self): - resp = self.client.get(f'/api/v1/shipment/shipments/{self.shipment1.id}/') + resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/") self.assertEqual(resp.status_code, status.HTTP_200_OK) result = resp.json() - self.assertEqual(result['id'], self.shipment1.id) - self.assertIn('area', result) - self.assertEqual(result['area'], 'A1') - self.assertIn('sales_items', result) - self.assertIsInstance(result['sales_items'], list) - self.assertIn('external_finished_products', result) - self.assertIsInstance(result['external_finished_products'], list) + self.assertEqual(result["id"], self.shipment1.id) + self.assertIn("area", result) + self.assertEqual(result["area"], "A1") + self.assertIn("sales_items", result) + self.assertIsInstance(result["sales_items"], list) + self.assertIn("external_finished_products", result) + self.assertIsInstance(result["external_finished_products"], list) def test_patch_shipment_area_success(self): """ 新增字段 area:支持更新(PATCH)并回显。 """ resp = self.client.patch( - f'/api/v1/shipment/shipments/{self.shipment1.id}/', - data={'area': '更新地区'}, - format='json' + f"/api/v1/shipment/shipments/{self.shipment1.id}/", + data={"area": "更新地区"}, + format="json", ) self.assertEqual(resp.status_code, status.HTTP_200_OK) result = resp.json() - self.assertEqual(result['id'], self.shipment1.id) - self.assertEqual(result.get('area', ''), '更新地区') + self.assertEqual(result["id"], self.shipment1.id) + self.assertEqual(result.get("area", ""), "更新地区") self.shipment1.refresh_from_db() - self.assertEqual(getattr(self.shipment1, 'area', ''), '更新地区') + self.assertEqual(getattr(self.shipment1, "area", ""), "更新地区") def test_list_shipments_includes_external_finished_product_remark(self): """ @@ -692,27 +696,193 @@ class ShipmentQueryAPITestCase(TestCase): # 给 shipment1 挂一个外部成品表 shipment_models.ExternalFinishedProduct.objects.create( shipment=self.shipment1, - style_name='款式X', + style_name="款式X", num_of_rolls=1, - remark='X备注', + remark="X备注", created_by=self.user1, ) - resp = self.client.get('/api/v1/shipment/shipments/') + resp = self.client.get("/api/v1/shipment/shipments/") self.assertEqual(resp.status_code, status.HTTP_200_OK) data = resp.json() - item = next(it for it in data['results'] if it['id'] == self.shipment1.id) - efps = item['external_finished_products'] + item = next(it for it in data["results"] if it["id"] == self.shipment1.id) + efps = item["external_finished_products"] self.assertIsInstance(efps, list) - target = next(p for p in efps if p['style_name'] == '款式X') - self.assertIn('remark', target) - self.assertEqual(target['remark'], 'X备注') + target = next(p for p in efps if p["style_name"] == "款式X") + self.assertIn("remark", target) + self.assertEqual(target["remark"], "X备注") def test_retrieve_other_merchant_shipment_404(self): - resp = self.client.get(f'/api/v1/shipment/shipments/{self.shipment2.id}/') + resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment2.id}/") self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) def test_list_shipments_unauthenticated(self): self.client.logout() - resp = self.client.get('/api/v1/shipment/shipments/') + resp = self.client.get("/api/v1/shipment/shipments/") + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + +class SalesItemCreateAPITestCase(APITestCase): + """销售品创建 API 测试""" + + def setUp(self): + from django.contrib.auth import get_user_model + from basic_info import models as basic_models + from printing import models as printing_models + from shipment import models as shipment_models + from decimal import Decimal + + User = get_user_model() + + # 创建商户 + self.merchant = basic_models.Merchant.objects.create( + name="测试商户", type=basic_models.MerchantTypeEnum.FACTORY + ) + + # 创建用户 + self.user = User.objects.create_user( + username="testuser_sales", + password="testpass123", + email="test_sales@example.com", + ) + + # 创建员工并关联商户 + self.employee = basic_models.Employee.objects.create( + sys_user=self.user, + merchant=self.merchant, + name="测试员工Sales", + mobile="13800138003", + status=basic_models.EmployeeStatusEnum.ACTIVE, + ) + + # 创建客户 + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name="测试客户Sales", + mobile="13900139003", + area="测试地区Sales", + ) + + # 创建产品 + self.product = basic_models.Product.objects.create( + merchant=self.merchant, + name="测试产品", + code="TEST001", + unit=basic_models.ProductUnitEnum.METER, + ) + + # 创建印染订单 + self.printing_order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, + customer=self.customer, + fabric="测试面料", + created_by=self.user, + ) + + # 创建印染任务 + self.printing_job = printing_models.PrintingJob.objects.create( + merchant=self.merchant, + printing_order=self.printing_order, + product=self.product, + quantity=100, + unit="米", + pieces=1, + description="测试任务", + created_by=self.user, + ) + + self.client.force_authenticate(user=self.user) + + def test_create_sales_item_success(self): + """测试成功创建销售品""" + data = { + "printing_job_id": self.printing_job.id, + "name": "测试销售品", + "quantity": "150.50", + "unit": 1, # 米 + "customer_id": self.customer.id, + "remark": "测试备注", + "position": "A1-01", + } + + resp = self.client.post("/api/v1/shipment/sales-items/", data, format="json") + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + result = resp.json() + self.assertEqual(result["name"], "测试销售品") + self.assertEqual(result["quantity"], "150.50") + self.assertEqual(result["unit"], 1) + self.assertEqual(result["unit_display"], "米") + self.assertEqual(result["printing_job_id"], self.printing_job.id) + self.assertEqual(result["customer_id"], self.customer.id) + self.assertIsNone(result["shipment_id"]) # 待分配状态 + self.assertEqual(result["created_by_id"], self.user.id) + + def test_create_sales_item_without_customer_id(self): + """测试不指定客户ID时自动从生产订单获取""" + data = { + "printing_job_id": self.printing_job.id, + "name": "测试销售品无客户", + "quantity": "100", + "unit": 1, + } + + resp = self.client.post("/api/v1/shipment/sales-items/", data, format="json") + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + result = resp.json() + # 客户ID应自动从生产订单获取 + self.assertEqual(result["customer_id"], self.customer.id) + + def test_create_sales_item_invalid_unit(self): + """测试无效的单位值""" + data = { + "printing_job_id": self.printing_job.id, + "name": "测试销售品", + "quantity": "100", + "unit": 99, # 无效单位 + } + + resp = self.client.post("/api/v1/shipment/sales-items/", data, format="json") + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("unit", resp.json()) + + def test_create_sales_item_invalid_printing_job(self): + """测试不存在的生产任务""" + data = { + "printing_job_id": 99999, + "name": "测试销售品", + "quantity": "100", + "unit": 1, + } + + resp = self.client.post("/api/v1/shipment/sales-items/", data, format="json") + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("detail", resp.json()) + + def test_create_sales_item_missing_required_fields(self): + """测试缺少必填字段""" + data = { + "printing_job_id": self.printing_job.id, + # 缺少 name, quantity, unit + } + + resp = self.client.post("/api/v1/shipment/sales-items/", data, format="json") + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + result = resp.json() + self.assertIn("name", result) + self.assertIn("quantity", result) + self.assertIn("unit", result) + + def test_create_sales_item_unauthenticated(self): + """测试未认证访问""" + self.client.logout() + data = { + "printing_job_id": self.printing_job.id, + "name": "测试销售品", + "quantity": "100", + "unit": 1, + } + + resp = self.client.post("/api/v1/shipment/sales-items/", data, format="json") self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index d1b386d..0128b04 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -1,6 +1,7 @@ """ Shipment API ViewSet """ + from rest_framework import status from rest_framework.views import APIView from rest_framework.response import Response @@ -23,10 +24,10 @@ from .serializers import ( class ShipmentListCreateView(ListModelMixin, GenericAPIView): """ 出货单:查询列表 / 创建 - + - GET /api/v1/shipment/shipments/ - POST /api/v1/shipment/shipments/ - + 请求体: { "customer": 1, @@ -34,7 +35,7 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView): "remark": "备注信息(可选)", "sales_items": [1, 2, 3] } - + 返回: { "id": 1, @@ -49,6 +50,7 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView): "updated_at": "2026-01-14T10:00:00Z" } """ + permission_classes = [IsAuthenticated] serializer_class = ShipmentSerializer pagination_class = LimitedLimitOffsetPagination @@ -64,70 +66,73 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView): - 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', + 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 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') + 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') + 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') + 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') + 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') + 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') + 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'], + 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', ''), + remark=data.get("remark", ""), + area=data.get("area", ""), ) except ValueError as e: - return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) - + 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) @@ -139,23 +144,26 @@ 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', + 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): + if getattr(user, "is_superuser", False): return qs - emp = getattr(user, 'employee', None) - merchant = getattr(emp, 'merchant', None) if emp else None + 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) @@ -175,24 +183,30 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView): data = serializer.validated_data # customer 变更需校验 merchant 一致 - if 'customer' in data: + if "customer" in data: from basic_info.models import Customer + try: - customer = Customer.objects.get(id=data['customer']) + customer = Customer.objects.get(id=data["customer"]) except Customer.DoesNotExist: - return Response({'detail': f'客户 {data["customer"]} 不存在'}, status=status.HTTP_400_BAD_REQUEST) + 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) + 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 + 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) @@ -208,14 +222,15 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView): class ShipmentExternalCreateView(APIView): """ 创建出货单(external 版) - + POST /api/v1/shipment/shipments/external/ - + 特点: - external_id 必填 - external_finished_products 必填(数组) - 不绑定任何销售品 """ + permission_classes = [IsAuthenticated] def post(self, request): @@ -226,18 +241,19 @@ class ShipmentExternalCreateView(APIView): 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'], + 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', ''), + remark=data.get("remark", ""), + area=data.get("area", ""), ) except ValueError as e: - return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + 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) @@ -246,14 +262,14 @@ class ShipmentExternalCreateView(APIView): class SalesItemByPrintingOrderView(APIView): """ 通过生产订单查询销售品 - + GET /api/v1/shipment/sales-items/by-printing-order// - + 返回与该 PrintingOrder 下所有 PrintingJob 关联的 SalesItem 列表。 - + 查询参数: - include_already_has_shipment: 是否包含已关联出货单的销售品(true/false),默认 false - + 返回: { "count": 5, @@ -278,35 +294,105 @@ class SalesItemByPrintingOrderView(APIView): ] } """ + permission_classes = [IsAuthenticated] - + def get(self, request, printing_order_id): # 验证生产订单是否存在 from printing.models import PrintingOrder + try: printing_order = PrintingOrder.objects.get(id=printing_order_id) except PrintingOrder.DoesNotExist: return Response( - {'detail': f'生产订单 {printing_order_id} 不存在'}, - status=status.HTTP_404_NOT_FOUND + {"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' - + 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, ) - + # 序列化返回 serializer = SalesItemSerializer(sales_items, many=True) - - return Response({ - 'count': len(serializer.data), - 'results': serializer.data - }) + + return Response({"count": len(serializer.data), "results": 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) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) diff --git a/docs/api_v1_sales_item_create.md b/docs/api_v1_sales_item_create.md new file mode 100644 index 0000000..da49076 --- /dev/null +++ b/docs/api_v1_sales_item_create.md @@ -0,0 +1,263 @@ +# SalesItem 创建 API 文档 + +## 概述 + +当自动转化销售品开关(`AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER`)关闭时,通过此 API 手动创建销售品。 + +## API 端点 + +``` +POST /api/v1/shipment/sales-items/ +``` + +## 请求头 + +``` +Authorization: Bearer +Content-Type: application/json +``` + +## 请求参数 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| printing_job_id | integer | 是 | 生产任务ID | +| name | string | 是 | 销售品名称(最大200字符) | +| quantity | string | 是 | 数量(支持小数,如:"100.50") | +| unit | integer | 是 | 单位(见下方单位可选值) | +| customer_id | integer | 否 | 客户ID(默认从生产订单获取) | +| remark | string | 否 | 备注(最大200字符) | +| position | string | 否 | 货位(最大200字符) | + +## 单位可选值 + +| 值 | 单位 | 说明 | +|----|------|------| +| 1 | 米 | 默认单位 | +| 2 | 件 | - | +| 3 | 码 | - | +| 4 | 个 | - | + +## 请求示例 + +```json +{ + "printing_job_id": 123, + "name": "产品A-2026-03", + "quantity": "150.50", + "unit": 1, + "customer_id": 456, + "remark": "加急订单", + "position": "A1-01" +} +``` + +## 响应示例 + +### 成功响应(201 Created) + +```json +{ + "id": 1, + "name": "产品A-2026-03", + "quantity": "150.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-03-05T10:30:00Z", + "created_by_id": 1, + "created_by_name": "张三" +} +``` + +**注意**:`shipment_id` 为 `null` 表示该销售品处于"待分配"状态,尚未关联出货单。 + +### 错误响应 + +#### 400 Bad Request - 缺少必填字段 + +```json +{ + "printing_job_id": ["此字段为必填项。"], + "name": ["此字段为必填项。"], + "quantity": ["此字段为必填项。"], + "unit": ["此字段为必填项。"] +} +``` + +#### 400 Bad Request - 生产任务不存在 + +```json +{ + "detail": "生产任务 123 不存在或不属于当前商户" +} +``` + +#### 400 Bad Request - 单位值无效 + +```json +{ + "unit": ["单位值无效。可选值:{1: '米', 2: '件', 3: '码', 4: '个'}"] +} +``` + +#### 400 Bad Request - 数量格式无效 + +```json +{ + "detail": "数量 abc 格式无效: invalid literal for int() with base 10: 'abc'" +} +``` + +#### 403 Forbidden - 用户未关联商户 + +```json +{ + "detail": "用户未关联商户,无法创建销售品" +} +``` + +#### 401 Unauthorized - 未认证 + +```json +{ + "detail": "身份认证信息未提供。" +} +``` + +## 权限说明 + +- **认证要求**:需要登录用户(`IsAuthenticated`) +- **商户隔离**:自动从当前用户的 `employee.merchant` 获取商户信息 +- **Django权限**:需要 `shipment.add_salesitem` 权限(遵循Django表级权限) +- **生产任务验证**:必须验证生产任务属于当前商户 + +## 业务逻辑 + +### 创建流程 + +1. **验证用户商户**:从 `request.user.employee.merchant` 获取当前商户 +2. **验证生产任务**:确认生产任务存在且属于当前商户 +3. **数据转换**:将数量字符串转换为 Decimal +4. **获取客户ID**:如果未提供,自动从生产订单获取 +5. **创建销售品**: + - `shipment = null`(待分配状态) + - `printing_job_id` 关联生产任务 + - `merchant` 设置为当前商户 + +### 状态说明 + +销售品没有独立的状态字段,通过 `shipment` 字段判断: + +- **待分配**:`shipment = null` +- **已关联出货单**:`shipment != null` + +创建后的销售品处于"待分配"状态,可通过出货单 API 关联到出货单。 + +## 使用场景 + +### 场景1:自动转化开关关闭时 + +当 `AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER = False` 时,系统不会自动创建销售品。此时需要通过此 API 手动创建: + +```python +# 生产任务完成后,手动创建销售品 +POST /api/v1/shipment/sales-items/ +{ + "printing_job_id": 123, + "name": "产品A", + "quantity": "100", + "unit": 1 +} +``` + +### 场景2:补充创建销售品 + +即使自动转化开关开启,也可以通过此 API 手动创建额外的销售品(用于特殊情况)。 + +### 场景3:修正销售品 + +如果自动创建的销售品信息有误,可以: +1. 删除自动创建的销售品 +2. 使用此 API 手动创建正确的销售品 + +## 相关 API + +### 查询销售品 + +``` +GET /api/v1/shipment/sales-items/by-printing-order//?include_already_has_shipment=false +``` + +### 创建出货单并关联销售品 + +``` +POST /api/v1/shipment/shipments/ +{ + "customer": 1, + "shipment_date": "2026-03-05", + "sales_items": [1, 2, 3] +} +``` + +## 注意事项 + +1. **商户隔离**:只能为当前用户所属商户创建销售品 +2. **生产任务验证**:必须提供有效的生产任务ID,且该任务必须属于当前商户 +3. **数量格式**:支持小数,使用字符串传递避免精度丢失 +4. **单位验证**:必须提供有效的单位值(1/2/3/4) +5. **重复创建**:同一个生产任务可以创建多个销售品(如果业务需要) + +## 测试示例 + +### curl 命令 + +```bash +curl -X POST http://localhost:8000/api/v1/shipment/sales-items/ \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "printing_job_id": 123, + "name": "测试产品", + "quantity": "100.50", + "unit": 1, + "remark": "测试备注" + }' +``` + +### Python 示例 + +```python +import requests + +data = { + "printing_job_id": 123, + "name": "产品A-2026-03", + "quantity": "150.50", + "unit": 1, # 米 + "customer_id": 456, + "remark": "加急订单", + "position": "A1-01" +} + +response = requests.post( + "http://localhost:8000/api/v1/shipment/sales-items/", + json=data, + headers={"Authorization": "Bearer YOUR_TOKEN"} +) + +if response.status_code == 201: + sales_item = response.json() + print(f"创建成功:{sales_item['name']} x {sales_item['quantity']} {sales_item['unit_display']}") +else: + print(f"错误:{response.json()}") +``` + +## 更新日志 + +- **2026-03-05**: API 首次创建,支持手动创建销售品 diff --git a/shipment/services.py b/shipment/services.py index c595ea2..f83ef5a 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -1,6 +1,7 @@ """ Shipment 模块业务逻辑层 """ + from __future__ import annotations from typing import List @@ -17,29 +18,29 @@ def get_sales_items_by_printing_order( ) -> QuerySet[SalesItem]: """ 通过生产订单ID查询对应的销售品 - + Args: printing_order_id: 生产订单ID include_already_has_shipment: 是否包含已关联出货单的销售品,默认为 False - + Returns: SalesItem 查询集 """ from printing.models import PrintingJob - + # 1. 获取该生产订单下所有 PrintingJob 的 ID job_ids = PrintingJob.objects.filter( printing_order_id=printing_order_id - ).values_list('id', flat=True) - + ).values_list("id", flat=True) + # 2. 查询 SalesItem,过滤 printing_job_id 在这些 job_ids 中 queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids)) - + # 3. 根据参数决定是否过滤已出货的销售品 if not include_already_has_shipment: queryset = queryset.filter(shipment__isnull=True) - - return queryset.select_related('shipment').order_by('id') + + return queryset.select_related("shipment").order_by("id") @transaction.atomic @@ -48,74 +49,76 @@ def create_shipment( shipment_date, sales_item_ids: List[int], created_by, - remark: str = '', - area: str = '', + remark: str = "", + area: str = "", ) -> Shipment: """ 创建出货单并关联销售品 - + Args: customer_id: 客户ID shipment_date: 出货日期 sales_item_ids: 要关联的销售品ID列表 created_by: 创建人 remark: 备注 - + Returns: 创建的 Shipment 实例 - + Raises: ValueError: 如果销售品不存在或已被关联到其他出货单 """ from basic_info.models import Customer - + # 验证客户存在 try: customer = Customer.objects.get(id=customer_id) except Customer.DoesNotExist: - raise ValueError(f'客户 {customer_id} 不存在') + raise ValueError(f"客户 {customer_id} 不存在") # merchant 隔离:必须能解析出当前用户 merchant - emp = getattr(created_by, 'employee', None) - merchant = getattr(emp, 'merchant', None) if emp else None + emp = getattr(created_by, "employee", None) + merchant = getattr(emp, "merchant", None) if emp else None if not merchant: # superuser 也必须绑定 merchant(避免产生无法隔离的数据) - raise ValueError('用户未关联商户,无法创建出货单') + raise ValueError("用户未关联商户,无法创建出货单") if customer.merchant_id != merchant.id: - raise ValueError('无权限为该客户创建出货单') - + raise ValueError("无权限为该客户创建出货单") + # 验证销售品 if sales_item_ids: # 查询销售品 sales_items = SalesItem.objects.filter(id__in=sales_item_ids) - found_ids = set(sales_items.values_list('id', flat=True)) + found_ids = set(sales_items.values_list("id", flat=True)) missing_ids = set(sales_item_ids) - found_ids - + if missing_ids: - raise ValueError(f'以下销售品不存在: {list(missing_ids)}') - + raise ValueError(f"以下销售品不存在: {list(missing_ids)}") + # 检查是否有已关联出货单的销售品 already_shipped = sales_items.filter(shipment__isnull=False) if already_shipped.exists(): - shipped_ids = list(already_shipped.values_list('id', flat=True)) - raise ValueError(f'以下销售品已关联到其他出货单: {shipped_ids}') - + shipped_ids = list(already_shipped.values_list("id", flat=True)) + raise ValueError(f"以下销售品已关联到其他出货单: {shipped_ids}") + # 创建出货单 shipment = Shipment.objects.create( merchant=merchant, customer=customer, shipment_date=shipment_date, - area=(area or '').strip(), + area=(area or "").strip(), remark=remark, created_by=created_by, ) - + # 关联销售品 if sales_item_ids: - updated = SalesItem.objects.filter(id__in=sales_item_ids, merchant=merchant).update(shipment=shipment) + updated = SalesItem.objects.filter( + id__in=sales_item_ids, merchant=merchant + ).update(shipment=shipment) if updated != len(sales_item_ids): - raise ValueError('存在不属于当前商户的销售品,无法关联到出货单') - + raise ValueError("存在不属于当前商户的销售品,无法关联到出货单") + return shipment @@ -126,12 +129,12 @@ def create_external_shipment( external_id: str, external_finished_products: List[dict], created_by, - remark: str = '', - area: str = '', + remark: str = "", + area: str = "", ) -> Shipment: """ 创建出货单(external 版),并批量写入外部成品表并关联到出货单。 - + 特点: - 不绑定任何 SalesItem - external_id 必填 @@ -143,27 +146,27 @@ def create_external_shipment( try: customer = Customer.objects.get(id=customer_id) except Customer.DoesNotExist: - raise ValueError(f'客户 {customer_id} 不存在') + raise ValueError(f"客户 {customer_id} 不存在") # merchant 隔离 - emp = getattr(created_by, 'employee', None) - merchant = getattr(emp, 'merchant', None) if emp else None + emp = getattr(created_by, "employee", None) + merchant = getattr(emp, "merchant", None) if emp else None if not merchant: - raise ValueError('用户未关联商户,无法创建出货单') + raise ValueError("用户未关联商户,无法创建出货单") if customer.merchant_id != merchant.id: - raise ValueError('无权限为该客户创建出货单') + raise ValueError("无权限为该客户创建出货单") - external_id = (external_id or '').strip() + external_id = (external_id or "").strip() if not external_id: - raise ValueError('external_id 不能为空') + raise ValueError("external_id 不能为空") if not external_finished_products: - raise ValueError('external_finished_products 不能为空') + raise ValueError("external_finished_products 不能为空") shipment = Shipment.objects.create( merchant=merchant, customer=customer, shipment_date=shipment_date, - area=(area or '').strip(), + area=(area or "").strip(), remark=remark, created_by=created_by, external_id=external_id, @@ -171,13 +174,94 @@ def create_external_shipment( objs = [] for item in external_finished_products: - objs.append(ExternalFinishedProduct( - shipment=shipment, - style_name=item.get('style_name', ''), - num_of_rolls=item.get('num_of_rolls', 0), - remark=item.get('remark') or '', - created_by=created_by, - )) + objs.append( + ExternalFinishedProduct( + shipment=shipment, + style_name=item.get("style_name", ""), + num_of_rolls=item.get("num_of_rolls", 0), + remark=item.get("remark") or "", + created_by=created_by, + ) + ) ExternalFinishedProduct.objects.bulk_create(objs) return shipment + + +@transaction.atomic +def create_sales_item( + printing_job_id: int, + name: str, + quantity: str, + unit: int, + created_by, + customer_id: int | None = None, + remark: str = "", + position: str = "", +) -> SalesItem: + """ + 手动创建销售品 + + Args: + printing_job_id: 生产任务ID(必填) + name: 销售品名称 + quantity: 数量(字符串,会被转换为Decimal) + unit: 单位(1=米, 2=件, 3=码, 4=个) + created_by: 创建人 + customer_id: 客户ID(可选) + remark: 备注(可选) + position: 货位(可选) + + Returns: + 创建的 SalesItem 实例 + + Raises: + ValueError: 如果生产任务不存在或不属于当前商户 + """ + from decimal import Decimal, InvalidOperation + from printing.models import PrintingJob + + # 获取当前用户的商户 + emp = getattr(created_by, "employee", None) + merchant = getattr(emp, "merchant", None) if emp else None + if not merchant: + raise ValueError("用户未关联商户,无法创建销售品") + + # 验证生产任务存在且属于当前商户 + try: + printing_job = PrintingJob.objects.select_related("printing_order").get( + id=printing_job_id, + merchant=merchant, + ) + except PrintingJob.DoesNotExist: + raise ValueError(f"生产任务 {printing_job_id} 不存在或不属于当前商户") + + # 转换数量为Decimal + try: + quantity_decimal = Decimal(str(quantity)) + except (InvalidOperation, ValueError, TypeError) as e: + raise ValueError(f"数量 {quantity} 格式无效: {e}") + + # 如果未提供客户ID,尝试从主订单获取 + if ( + customer_id is None + and printing_job.printing_order + and printing_job.printing_order.customer + ): + customer_id = printing_job.printing_order.customer_id + + # 创建销售品 + sales_item = SalesItem.objects.create( + shipment=None, # 初始状态:待分配 + merchant=merchant, + name=name, + quantity=quantity_decimal, + unit=unit, + created_by=created_by, + printing_job_id=printing_job.id, + customer_id=customer_id, + remark=remark, + position=position, + ) + + return sales_item