1
0
forked from erp-dev/erp

Merge branch 'settlement'

This commit is contained in:
2026-03-10 19:33:35 +08:00
6 changed files with 1469 additions and 583 deletions

View File

@@ -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_sales import views as pre_sales_views
from .views.business.pre_purchase import views as pre_purchase_views from .views.business.pre_purchase import views as pre_purchase_views
from .views.stock_change_views.snapshot import StockSnapshotListView 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.upload import UploadFileViewSet
from .views.products import ProductQuickViewSet from .views.products import ProductQuickViewSet
from .views.parameters import StateParameterViewSet from .views.parameters import StateParameterViewSet
@@ -30,6 +34,7 @@ from .views.users import CreateUserWithProfileView
from .views.mingdaoyun import MDYPlateOrderStagingViewSet from .views.mingdaoyun import MDYPlateOrderStagingViewSet
from .views.shipment import ( from .views.shipment import (
SalesItemByPrintingOrderView, SalesItemByPrintingOrderView,
SalesItemCreateView,
ShipmentListCreateView, ShipmentListCreateView,
ShipmentDetailView, ShipmentDetailView,
ShipmentExternalCreateView, ShipmentExternalCreateView,
@@ -38,132 +43,282 @@ from .views.settlement.views import PlateOrderSummaryView
# 创建 DRF Router for Stateflow # 创建 DRF Router for Stateflow
stateflow_router = DefaultRouter() stateflow_router = DefaultRouter()
stateflow_router.register(r'states', stateflow.StateViewSet, basename='state') stateflow_router.register(r"states", stateflow.StateViewSet, basename="state")
stateflow_router.register(r'processes', stateflow.ProcessViewSet, basename='process') stateflow_router.register(r"processes", stateflow.ProcessViewSet, basename="process")
stateflow_router.register(r'business-objects', stateflow.BusinessObjectViewSet, basename='business-object') stateflow_router.register(
r"business-objects", stateflow.BusinessObjectViewSet, basename="business-object"
)
# 创建主 Router # 创建主 Router
main_router = DefaultRouter() main_router = DefaultRouter()
main_router.register(r'printing-orders', PrintingOrderViewSet, basename='printing-order') main_router.register(
main_router.register(r'printing-jobs', PrintingJobViewSet, basename='printing-job') r"printing-orders", PrintingOrderViewSet, basename="printing-order"
main_router.register(r'plate-orders', PlateOrderViewSet, basename='plate-order') )
main_router.register(r'upload', UploadFileViewSet, basename='upload') main_router.register(r"printing-jobs", PrintingJobViewSet, basename="printing-job")
main_router.register(r'products/quick', ProductQuickViewSet, basename='product-quick') main_router.register(r"plate-orders", PlateOrderViewSet, basename="plate-order")
main_router.register(r'parameters', StateParameterViewSet, basename='parameter') main_router.register(r"upload", UploadFileViewSet, basename="upload")
main_router.register(r'mdy-plate-order-staging', MDYPlateOrderStagingViewSet, basename='mdy-plate-order-staging') 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 = [ urlpatterns = [
# 库存变动相关API # 库存变动相关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/<int:record_id>/', stock_change_views.get_stock_change, name='get_stock_change'),
path('stock-change/<int:record_id>/finish/', stock_change_views.finish_stock_change, name='finish_stock_change'),
path('stock-change/<int:record_id>/offset/', stock_change_views.offset_stock_change, name='offset_stock_change'),
path( path(
'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/<int:record_id>/",
stock_change_views.get_stock_change,
name="get_stock_change",
),
path(
"stock-change/<int:record_id>/finish/",
stock_change_views.finish_stock_change,
name="finish_stock_change",
),
path(
"stock-change/<int:record_id>/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, stock_change_views.set_merchant_auto_complete_stock_change,
name='set_merchant_auto_complete_stock_change', name="set_merchant_auto_complete_stock_change",
), ),
# 用户信息 API # 用户信息 API
path('user-info/', user_info.user_info, name='user_info'), path("user-info/", user_info.user_info, name="user_info"),
# 用户创建 API # 用户创建 API
path('users/create/', CreateUserWithProfileView.as_view(), name='create_user_with_profile'), path(
"users/create/",
CreateUserWithProfileView.as_view(),
name="create_user_with_profile",
),
# 库存查询 API # 库存查询 API
path('inventory/', inventory.InventoryAPIView.as_view(), name='inventory'), path("inventory/", inventory.InventoryAPIView.as_view(), name="inventory"),
path('purchase-orders/', purchase_views.PurchaseOrderView.as_view(), name='purchase_orders'),
path('purchase-orders/<int:pk>/', purchase_views.PurchaseOrderDetailView.as_view(), name='purchase_order_detail'),
path('purchase-orders/<int:pk>/review/', purchase_views.PurchaseOrderReviewView.as_view(), name='purchase_order_review'),
path( path(
'purchase-orders/<int:pk>/bind-stock-change/', "purchase-orders/",
purchase_views.PurchaseOrderView.as_view(),
name="purchase_orders",
),
path(
"purchase-orders/<int:pk>/",
purchase_views.PurchaseOrderDetailView.as_view(),
name="purchase_order_detail",
),
path(
"purchase-orders/<int:pk>/review/",
purchase_views.PurchaseOrderReviewView.as_view(),
name="purchase_order_review",
),
path(
"purchase-orders/<int:pk>/bind-stock-change/",
purchase_views.PurchaseOrderBindStockChangeView.as_view(), 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/<int:pk>/', purchase_return_views.PurchaseReturnOrderDetailView.as_view(), name='purchase_return_order_detail'),
path('purchase-return-orders/<int:pk>/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/<int:pk>/', sales_views.SalesOrderDetailView.as_view(), name='sales_order_detail'),
path('sales-orders/<int:pk>/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/<int:pk>/', sales_return_views.SalesReturnOrderDetailView.as_view(), name='sales_return_order_detail'),
path('sales-return-orders/<int:pk>/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/<int:pk>/', pre_sales_views.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'),
path( path(
'pre-sales-orders/<int:pk>/convert-to-sales/', "purchase-return-orders/",
purchase_return_views.PurchaseReturnOrderView.as_view(),
name="purchase_return_orders",
),
path(
"purchase-return-orders/<int:pk>/",
purchase_return_views.PurchaseReturnOrderDetailView.as_view(),
name="purchase_return_order_detail",
),
path(
"purchase-return-orders/<int:pk>/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/<int:pk>/",
sales_views.SalesOrderDetailView.as_view(),
name="sales_order_detail",
),
path(
"sales-orders/<int:pk>/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/<int:pk>/",
sales_return_views.SalesReturnOrderDetailView.as_view(),
name="sales_return_order_detail",
),
path(
"sales-return-orders/<int:pk>/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/<int:pk>/",
pre_sales_views.PreSalesOrderDetailView.as_view(),
name="pre_sales_order_detail",
),
path(
"pre-sales-orders/<int:pk>/convert-to-sales/",
pre_sales_views.PreSalesOrderConvertToSalesOrderView.as_view(), pre_sales_views.PreSalesOrderConvertToSalesOrderView.as_view(),
name='pre_sales_order_convert_to_sales', name="pre_sales_order_convert_to_sales",
), ),
path( path(
'pre-sales-order-items/<int:item_id>/allocations/', "pre-sales-order-items/<int:item_id>/allocations/",
pre_sales_views.PreSalesOrderItemAllocationView.as_view(), pre_sales_views.PreSalesOrderItemAllocationView.as_view(),
name='pre_sales_order_item_allocations', name="pre_sales_order_item_allocations",
), ),
path( path(
'pre-sales-order-items/<int:item_id>/allocations/<int:pk>/', "pre-sales-order-items/<int:item_id>/allocations/<int:pk>/",
pre_sales_views.PreSalesOrderItemAllocationDetailView.as_view(), 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/<int:pk>/",
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/<int:pk>/review/",
payment_views.PaymentOrderReviewView.as_view(),
name="payment_order_review",
),
path(
"receipt-orders/",
receipt_views.ReceiptOrderView.as_view(),
name="receipt_orders",
),
path(
"receipt-orders/<int:pk>/review/",
receipt_views.ReceiptOrderReviewView.as_view(),
name="receipt_order_review",
),
path(
"customers/<int:customer_id>/balance/",
balance_views.CustomerBalanceView.as_view(),
name="customer_balance",
),
path(
"suppliers/<int:supplier_id>/balance/",
balance_views.SupplierBalanceView.as_view(),
name="supplier_balance",
),
path(
"customers/<int:customer_id>/statements/",
statement_views.CustomerStatementView.as_view(),
name="customer_statements",
),
path(
"suppliers/<int:supplier_id>/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/<int:pk>/', 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/<int:pk>/review/', payment_views.PaymentOrderReviewView.as_view(), name='payment_order_review'),
path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'),
path('receipt-orders/<int:pk>/review/', receipt_views.ReceiptOrderReviewView.as_view(), name='receipt_order_review'),
path('customers/<int:customer_id>/balance/', balance_views.CustomerBalanceView.as_view(), name='customer_balance'),
path('suppliers/<int:supplier_id>/balance/', balance_views.SupplierBalanceView.as_view(), name='supplier_balance'),
path('customers/<int:customer_id>/statements/', statement_views.CustomerStatementView.as_view(), name='customer_statements'),
path('suppliers/<int:supplier_id>/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 # 产品图片上传 API
path('products/<int:product_id>/image/', product_image.ProductImageUploadView.as_view(), name='product_image_upload'), path(
"products/<int:product_id>/image/",
product_image.ProductImageUploadView.as_view(),
name="product_image_upload",
),
# Tencent Cloud (TIIA) API # 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) # Stateflow API (使用 Router)
path('stateflow/', include(stateflow_router.urls)), path("stateflow/", include(stateflow_router.urls)),
# Shipment API # Shipment API
path( path(
'shipment/shipments/', "shipment/shipments/",
ShipmentListCreateView.as_view(), ShipmentListCreateView.as_view(),
name='shipment_list_create' name="shipment_list_create",
), ),
path( path(
'shipment/shipments/<int:pk>/', "shipment/shipments/<int:pk>/",
ShipmentDetailView.as_view(), ShipmentDetailView.as_view(),
name='shipment_detail' name="shipment_detail",
), ),
path( path(
'shipment/shipments/external/', "shipment/shipments/external/",
ShipmentExternalCreateView.as_view(), ShipmentExternalCreateView.as_view(),
name='shipment_create_external' name="shipment_create_external",
), ),
path( path(
'shipment/sales-items/by-printing-order/<int:printing_order_id>/', "shipment/sales-items/by-printing-order/<int:printing_order_id>/",
SalesItemByPrintingOrderView.as_view(), 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 # Settlement API
path( path(
'settlement/plate-orders/summary/', "settlement/plate-orders/summary/",
PlateOrderSummaryView.as_view(), PlateOrderSummaryView.as_view(),
name='plate_order_summary' name="plate_order_summary",
), ),
# 主 Router (printing-orders 等) # 主 Router (printing-orders 等)
path('', include(main_router.urls)), path("", include(main_router.urls)),
] ]

View File

@@ -1,6 +1,7 @@
""" """
Shipment API 序列化器 Shipment API 序列化器
""" """
from rest_framework import serializers from rest_framework import serializers
from shipment.models import ExternalFinishedProduct, SalesItem, Shipment from shipment.models import ExternalFinishedProduct, SalesItem, Shipment
@@ -10,38 +11,55 @@ 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() created_by_name = serializers.SerializerMethodField()
items_count = 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() 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() external_finished_products_count = serializers.SerializerMethodField()
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True) merchant_id = serializers.IntegerField(source="merchant.id", read_only=True)
merchant_name = serializers.CharField(source='merchant.name', read_only=True) merchant_name = serializers.CharField(source="merchant.name", read_only=True)
sales_items = serializers.SerializerMethodField() sales_items = serializers.SerializerMethodField()
external_finished_products = serializers.SerializerMethodField() external_finished_products = serializers.SerializerMethodField()
class Meta: class Meta:
model = Shipment model = Shipment
fields = [ fields = [
'id', 'merchant_id', 'merchant_name', "id",
'customer', 'customer_name', 'shipment_date', 'area', 'remark', "merchant_id",
'status', 'status_display', "merchant_name",
'external_id', "customer",
'cancelled_at', 'cancelled_by_id', 'cancelled_by_name', "customer_name",
'items_count', 'created_by_id', 'created_by_name', "shipment_date",
'external_finished_products_count', "area",
'sales_items', "remark",
'external_finished_products', "status",
'created_at', 'updated_at' "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): def get_created_by_name(self, obj):
if obj.created_by: if obj.created_by:
employee = getattr(obj.created_by, 'employee', None) employee = getattr(obj.created_by, "employee", None)
if employee: if employee:
return employee.name return employee.name
return obj.created_by.username return obj.created_by.username
@@ -52,7 +70,7 @@ class ShipmentSerializer(serializers.ModelSerializer):
def get_cancelled_by_name(self, obj): def get_cancelled_by_name(self, obj):
if obj.cancelled_by: if obj.cancelled_by:
employee = getattr(obj.cancelled_by, 'employee', None) employee = getattr(obj.cancelled_by, "employee", None)
if employee: if employee:
return employee.name return employee.name
return obj.cancelled_by.username return obj.cancelled_by.username
@@ -66,7 +84,7 @@ class ShipmentSerializer(serializers.ModelSerializer):
出货单关联的销售品明细(无则返回空数组)。 出货单关联的销售品明细(无则返回空数组)。
""" """
# 优先使用 prefetch 的 related manager兜底为 none() # 优先使用 prefetch 的 related manager兜底为 none()
rel = getattr(obj, 'items', None) rel = getattr(obj, "items", None)
items = list(rel.all()) if rel is not None else [] items = list(rel.all()) if rel is not None else []
return SalesItemSerializer(items, many=True).data 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 [] products = list(rel.all()) if rel is not None else []
return ExternalFinishedProductSerializer(products, many=True).data 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() created_by_name = serializers.SerializerMethodField()
class Meta: class Meta:
model = ExternalFinishedProduct model = ExternalFinishedProduct
fields = [ fields = [
'id', "id",
'style_name', "style_name",
'num_of_rolls', "num_of_rolls",
'remark', "remark",
'created_at', "created_at",
'created_by_id', "created_by_id",
'created_by_name', "created_by_name",
] ]
read_only_fields = fields read_only_fields = fields
def get_created_by_name(self, obj): def get_created_by_name(self, obj):
if obj.created_by: if obj.created_by:
employee = getattr(obj.created_by, 'employee', None) employee = getattr(obj.created_by, "employee", None)
if employee: if employee:
return employee.name return employee.name
return obj.created_by.username return obj.created_by.username
@@ -112,26 +133,39 @@ class ShipmentCreateNormalSerializer(serializers.Serializer):
""" """
出货单创建序列化器(普通版) 出货单创建序列化器(普通版)
""" """
customer = serializers.IntegerField(help_text='客户ID')
shipment_date = serializers.DateField(help_text='出货日期') customer = serializers.IntegerField(help_text="客户ID")
area = serializers.CharField(max_length=30, required=False, default='', allow_blank=True, help_text='出货地区(可选)') shipment_date = serializers.DateField(help_text="出货日期")
remark = serializers.CharField(required=False, default='', allow_blank=True, 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( sales_items = serializers.ListField(
child=serializers.IntegerField(), child=serializers.IntegerField(),
required=False, required=False,
default=list, default=list,
help_text='要关联的销售品ID列表' help_text="要关联的销售品ID列表",
) )
def validate_sales_items(self, value): def validate_sales_items(self, value):
# 去重 # 去重
return list(set(value)) if value else [] return list(set(value)) if value else []
class ExternalFinishedProductInputSerializer(serializers.Serializer): class ExternalFinishedProductInputSerializer(serializers.Serializer):
"""外部成品表写入结构external create 专用)""" """外部成品表写入结构external create 专用)"""
style_name = serializers.CharField(max_length=200) style_name = serializers.CharField(max_length=200)
num_of_rolls = serializers.IntegerField(min_value=0) 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): class ShipmentCreateExternalSerializer(serializers.Serializer):
@@ -143,26 +177,35 @@ class ShipmentCreateExternalSerializer(serializers.Serializer):
- 必须提供 external_id - 必须提供 external_id
- 同时写入 ExternalFinishedProduct 列表并关联到 Shipment - 同时写入 ExternalFinishedProduct 列表并关联到 Shipment
""" """
customer = serializers.IntegerField(help_text='客户ID')
shipment_date = serializers.DateField(help_text='出货日期') customer = serializers.IntegerField(help_text="客户ID")
area = serializers.CharField(max_length=30, required=False, default='', allow_blank=True, help_text='出货地区(可选)') shipment_date = serializers.DateField(help_text="出货日期")
remark = serializers.CharField(required=False, default='', allow_blank=True, help_text='备注') area = serializers.CharField(
external_id = serializers.CharField(max_length=120, help_text='外部订单号(必填)') 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( external_finished_products = serializers.ListField(
child=ExternalFinishedProductInputSerializer(), child=ExternalFinishedProductInputSerializer(),
required=True, required=True,
help_text='外部成品表结构数组(必填)' help_text="外部成品表结构数组(必填)",
) )
def validate_external_id(self, value): def validate_external_id(self, value):
value = (value or '').strip() value = (value or "").strip()
if not value: if not value:
raise serializers.ValidationError('external_id 不能为空') raise serializers.ValidationError("external_id 不能为空")
return value return value
def validate_external_finished_products(self, value): def validate_external_finished_products(self, value):
if not value: if not value:
raise serializers.ValidationError('external_finished_products 不能为空') raise serializers.ValidationError("external_finished_products 不能为空")
return value return value
@@ -172,11 +215,22 @@ class ShipmentUpdateSerializer(serializers.Serializer):
说明:本次新增字段 area需要保证更新入口可写入并回显。 说明:本次新增字段 area需要保证更新入口可写入并回显。
""" """
customer = serializers.IntegerField(required=False, help_text='客户ID可选')
shipment_date = serializers.DateField(required=False, help_text='出货日期(可选)') customer = serializers.IntegerField(required=False, help_text="客户ID(可选)")
area = serializers.CharField(max_length=30, required=False, allow_blank=True, help_text='出货地区(可选)') shipment_date = serializers.DateField(required=False, help_text="出货日期(可选)")
remark = serializers.CharField(required=False, allow_blank=True, help_text='备注(可选)') area = serializers.CharField(
external_id = serializers.CharField(max_length=120, required=False, allow_blank=True, allow_null=True, help_text='外部订单号(可选)') 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): class SalesItemSerializer(serializers.Serializer):
@@ -185,6 +239,7 @@ class SalesItemSerializer(serializers.Serializer):
用于返回销售品数据 用于返回销售品数据
""" """
id = serializers.IntegerField(read_only=True) id = serializers.IntegerField(read_only=True)
name = serializers.CharField(read_only=True) name = serializers.CharField(read_only=True)
quantity = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True) quantity = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True)
@@ -194,10 +249,16 @@ class SalesItemSerializer(serializers.Serializer):
remark = serializers.CharField(read_only=True) remark = serializers.CharField(read_only=True)
printing_job_id = serializers.IntegerField(read_only=True) printing_job_id = serializers.IntegerField(read_only=True)
customer_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_id = serializers.IntegerField(
shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True, allow_null=True) 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_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() created_by_name = serializers.SerializerMethodField()
def get_unit_display(self, obj): def get_unit_display(self, obj):
@@ -205,8 +266,75 @@ class SalesItemSerializer(serializers.Serializer):
def get_created_by_name(self, obj): def get_created_by_name(self, obj):
if obj.created_by: if obj.created_by:
employee = getattr(obj.created_by, 'employee', None) employee = getattr(obj.created_by, "employee", None)
if employee: if employee:
return employee.name return employee.name
return obj.created_by.username return obj.created_by.username
return None 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", ""),
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
""" """
Shipment API ViewSet Shipment API ViewSet
""" """
from rest_framework import status from rest_framework import status
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
@@ -49,6 +50,7 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
"updated_at": "2026-01-14T10:00:00Z" "updated_at": "2026-01-14T10:00:00Z"
} }
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
serializer_class = ShipmentSerializer serializer_class = ShipmentSerializer
pagination_class = LimitedLimitOffsetPagination pagination_class = LimitedLimitOffsetPagination
@@ -64,44 +66,46 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
- shipment_date_from: 出货日期起始YYYY-MM-DD - shipment_date_from: 出货日期起始YYYY-MM-DD
- shipment_date_to: 出货日期结束YYYY-MM-DD包含整天 - shipment_date_to: 出货日期结束YYYY-MM-DD包含整天
""" """
qs = Shipment.objects.all().select_related( qs = (
'merchant', 'customer', 'created_by', 'cancelled_by' Shipment.objects.all()
).prefetch_related( .select_related("merchant", "customer", "created_by", "cancelled_by")
'items', .prefetch_related(
'external_finished_products', "items",
"external_finished_products",
)
) )
user = self.request.user user = self.request.user
if not getattr(user, 'is_superuser', False): if not getattr(user, "is_superuser", False):
emp = getattr(user, 'employee', None) emp = getattr(user, "employee", None)
merchant = getattr(emp, 'merchant', None) if emp else None merchant = getattr(emp, "merchant", None) if emp else None
if not merchant: if not merchant:
return Shipment.objects.none() return Shipment.objects.none()
qs = qs.filter(merchant=merchant) qs = qs.filter(merchant=merchant)
# optional filters # optional filters
customer_id = self.request.query_params.get('customer') customer_id = self.request.query_params.get("customer")
if customer_id: if customer_id:
qs = qs.filter(customer_id=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: if status_val:
qs = qs.filter(status=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: if external_id:
qs = qs.filter(external_id=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: if date_from:
qs = qs.filter(shipment_date__gte=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: if date_to:
# 通过 <= 过滤日期 # 通过 <= 过滤日期
qs = qs.filter(shipment_date__lte=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): def get(self, request):
return self.list(request) return self.list(request)
@@ -116,17 +120,18 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
# 调用业务逻辑 # 调用业务逻辑
from shipment.services import create_shipment from shipment.services import create_shipment
try: try:
shipment = create_shipment( shipment = create_shipment(
customer_id=data['customer'], customer_id=data["customer"],
shipment_date=data['shipment_date'], shipment_date=data["shipment_date"],
sales_item_ids=data['sales_items'], sales_item_ids=data["sales_items"],
created_by=request.user, created_by=request.user,
remark=data.get('remark', ''), remark=data.get("remark", ""),
area=data.get('area', ''), area=data.get("area", ""),
) )
except ValueError as e: 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) response_serializer = ShipmentSerializer(shipment)
@@ -139,23 +144,26 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView):
GET /api/v1/shipment/shipments/<id>/ GET /api/v1/shipment/shipments/<id>/
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
serializer_class = ShipmentSerializer serializer_class = ShipmentSerializer
def get_queryset(self): def get_queryset(self):
qs = Shipment.objects.all().select_related( qs = (
'merchant', 'customer', 'created_by', 'cancelled_by' Shipment.objects.all()
).prefetch_related( .select_related("merchant", "customer", "created_by", "cancelled_by")
'items', .prefetch_related(
'external_finished_products', "items",
"external_finished_products",
)
) )
user = self.request.user user = self.request.user
if getattr(user, 'is_superuser', False): if getattr(user, "is_superuser", False):
return qs return qs
emp = getattr(user, 'employee', None) emp = getattr(user, "employee", None)
merchant = getattr(emp, 'merchant', None) if emp else None merchant = getattr(emp, "merchant", None) if emp else None
if not merchant: if not merchant:
return Shipment.objects.none() return Shipment.objects.none()
return qs.filter(merchant=merchant) return qs.filter(merchant=merchant)
@@ -175,24 +183,30 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView):
data = serializer.validated_data data = serializer.validated_data
# customer 变更需校验 merchant 一致 # customer 变更需校验 merchant 一致
if 'customer' in data: if "customer" in data:
from basic_info.models import Customer from basic_info.models import Customer
try: try:
customer = Customer.objects.get(id=data['customer']) customer = Customer.objects.get(id=data["customer"])
except Customer.DoesNotExist: 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: 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 shipment.customer = customer
if 'shipment_date' in data: if "shipment_date" in data:
shipment.shipment_date = data['shipment_date'] shipment.shipment_date = data["shipment_date"]
if 'area' in data: if "area" in data:
shipment.area = (data.get('area') or '').strip() shipment.area = (data.get("area") or "").strip()
if 'remark' in data: if "remark" in data:
shipment.remark = data.get('remark') or '' shipment.remark = data.get("remark") or ""
if 'external_id' in data: if "external_id" in data:
shipment.external_id = (data.get('external_id') or '').strip() or None shipment.external_id = (data.get("external_id") or "").strip() or None
shipment.save() shipment.save()
return Response(ShipmentSerializer(shipment).data, status=status.HTTP_200_OK) return Response(ShipmentSerializer(shipment).data, status=status.HTTP_200_OK)
@@ -216,6 +230,7 @@ class ShipmentExternalCreateView(APIView):
- external_finished_products 必填(数组) - external_finished_products 必填(数组)
- 不绑定任何销售品 - 不绑定任何销售品
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def post(self, request): def post(self, request):
@@ -226,18 +241,19 @@ class ShipmentExternalCreateView(APIView):
data = serializer.validated_data data = serializer.validated_data
from shipment.services import create_external_shipment from shipment.services import create_external_shipment
try: try:
shipment = create_external_shipment( shipment = create_external_shipment(
customer_id=data['customer'], customer_id=data["customer"],
shipment_date=data['shipment_date'], shipment_date=data["shipment_date"],
external_id=data['external_id'], external_id=data["external_id"],
external_finished_products=data['external_finished_products'], external_finished_products=data["external_finished_products"],
created_by=request.user, created_by=request.user,
remark=data.get('remark', ''), remark=data.get("remark", ""),
area=data.get('area', ''), area=data.get("area", ""),
) )
except ValueError as e: 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) response_serializer = ShipmentSerializer(shipment)
return Response(response_serializer.data, status=status.HTTP_201_CREATED) return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@@ -278,26 +294,30 @@ class SalesItemByPrintingOrderView(APIView):
] ]
} }
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def get(self, request, printing_order_id): def get(self, request, printing_order_id):
# 验证生产订单是否存在 # 验证生产订单是否存在
from printing.models import PrintingOrder from printing.models import PrintingOrder
try: try:
printing_order = PrintingOrder.objects.get(id=printing_order_id) printing_order = PrintingOrder.objects.get(id=printing_order_id)
except PrintingOrder.DoesNotExist: except PrintingOrder.DoesNotExist:
return Response( return Response(
{'detail': f'生产订单 {printing_order_id} 不存在'}, {"detail": f"生产订单 {printing_order_id} 不存在"},
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND,
) )
# 获取查询参数 # 获取查询参数
include_already_has_shipment = request.query_params.get( include_already_has_shipment = (
'include_already_has_shipment', 'false' request.query_params.get("include_already_has_shipment", "false").lower()
).lower() == 'true' == "true"
)
# 调用 shipment 业务逻辑 # 调用 shipment 业务逻辑
from shipment.services import get_sales_items_by_printing_order from shipment.services import get_sales_items_by_printing_order
sales_items = get_sales_items_by_printing_order( sales_items = get_sales_items_by_printing_order(
printing_order_id=printing_order.id, printing_order_id=printing_order.id,
include_already_has_shipment=include_already_has_shipment, include_already_has_shipment=include_already_has_shipment,
@@ -306,7 +326,73 @@ class SalesItemByPrintingOrderView(APIView):
# 序列化返回 # 序列化返回
serializer = SalesItemSerializer(sales_items, many=True) serializer = SalesItemSerializer(sales_items, many=True)
return Response({ return Response({"count": len(serializer.data), "results": serializer.data})
'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)

View File

@@ -0,0 +1,263 @@
# SalesItem 创建 API 文档
## 概述
当自动转化销售品开关(`AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER`)关闭时,通过此 API 手动创建销售品。
## API 端点
```
POST /api/v1/shipment/sales-items/
```
## 请求头
```
Authorization: Bearer <token>
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/<printing_order_id>/?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 首次创建,支持手动创建销售品

View File

@@ -1,6 +1,7 @@
""" """
Shipment 模块业务逻辑层 Shipment 模块业务逻辑层
""" """
from __future__ import annotations from __future__ import annotations
from typing import List from typing import List
@@ -30,7 +31,7 @@ def get_sales_items_by_printing_order(
# 1. 获取该生产订单下所有 PrintingJob 的 ID # 1. 获取该生产订单下所有 PrintingJob 的 ID
job_ids = PrintingJob.objects.filter( job_ids = PrintingJob.objects.filter(
printing_order_id=printing_order_id printing_order_id=printing_order_id
).values_list('id', flat=True) ).values_list("id", flat=True)
# 2. 查询 SalesItem过滤 printing_job_id 在这些 job_ids 中 # 2. 查询 SalesItem过滤 printing_job_id 在这些 job_ids 中
queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids)) queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids))
@@ -39,7 +40,7 @@ def get_sales_items_by_printing_order(
if not include_already_has_shipment: if not include_already_has_shipment:
queryset = queryset.filter(shipment__isnull=True) queryset = queryset.filter(shipment__isnull=True)
return queryset.select_related('shipment').order_by('id') return queryset.select_related("shipment").order_by("id")
@transaction.atomic @transaction.atomic
@@ -48,8 +49,8 @@ def create_shipment(
shipment_date, shipment_date,
sales_item_ids: List[int], sales_item_ids: List[int],
created_by, created_by,
remark: str = '', remark: str = "",
area: str = '', area: str = "",
) -> Shipment: ) -> Shipment:
""" """
创建出货单并关联销售品 创建出货单并关联销售品
@@ -73,48 +74,50 @@ def create_shipment(
try: try:
customer = Customer.objects.get(id=customer_id) customer = Customer.objects.get(id=customer_id)
except Customer.DoesNotExist: except Customer.DoesNotExist:
raise ValueError(f'客户 {customer_id} 不存在') raise ValueError(f"客户 {customer_id} 不存在")
# merchant 隔离:必须能解析出当前用户 merchant # merchant 隔离:必须能解析出当前用户 merchant
emp = getattr(created_by, 'employee', None) emp = getattr(created_by, "employee", None)
merchant = getattr(emp, 'merchant', None) if emp else None merchant = getattr(emp, "merchant", None) if emp else None
if not merchant: if not merchant:
# superuser 也必须绑定 merchant避免产生无法隔离的数据 # superuser 也必须绑定 merchant避免产生无法隔离的数据
raise ValueError('用户未关联商户,无法创建出货单') raise ValueError("用户未关联商户,无法创建出货单")
if customer.merchant_id != merchant.id: if customer.merchant_id != merchant.id:
raise ValueError('无权限为该客户创建出货单') raise ValueError("无权限为该客户创建出货单")
# 验证销售品 # 验证销售品
if sales_item_ids: if sales_item_ids:
# 查询销售品 # 查询销售品
sales_items = SalesItem.objects.filter(id__in=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 missing_ids = set(sales_item_ids) - found_ids
if missing_ids: if missing_ids:
raise ValueError(f'以下销售品不存在: {list(missing_ids)}') raise ValueError(f"以下销售品不存在: {list(missing_ids)}")
# 检查是否有已关联出货单的销售品 # 检查是否有已关联出货单的销售品
already_shipped = sales_items.filter(shipment__isnull=False) already_shipped = sales_items.filter(shipment__isnull=False)
if already_shipped.exists(): if already_shipped.exists():
shipped_ids = list(already_shipped.values_list('id', flat=True)) shipped_ids = list(already_shipped.values_list("id", flat=True))
raise ValueError(f'以下销售品已关联到其他出货单: {shipped_ids}') raise ValueError(f"以下销售品已关联到其他出货单: {shipped_ids}")
# 创建出货单 # 创建出货单
shipment = Shipment.objects.create( shipment = Shipment.objects.create(
merchant=merchant, merchant=merchant,
customer=customer, customer=customer,
shipment_date=shipment_date, shipment_date=shipment_date,
area=(area or '').strip(), area=(area or "").strip(),
remark=remark, remark=remark,
created_by=created_by, created_by=created_by,
) )
# 关联销售品 # 关联销售品
if sales_item_ids: 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): if updated != len(sales_item_ids):
raise ValueError('存在不属于当前商户的销售品,无法关联到出货单') raise ValueError("存在不属于当前商户的销售品,无法关联到出货单")
return shipment return shipment
@@ -126,8 +129,8 @@ def create_external_shipment(
external_id: str, external_id: str,
external_finished_products: List[dict], external_finished_products: List[dict],
created_by, created_by,
remark: str = '', remark: str = "",
area: str = '', area: str = "",
) -> Shipment: ) -> Shipment:
""" """
创建出货单external 版),并批量写入外部成品表并关联到出货单。 创建出货单external 版),并批量写入外部成品表并关联到出货单。
@@ -143,27 +146,27 @@ def create_external_shipment(
try: try:
customer = Customer.objects.get(id=customer_id) customer = Customer.objects.get(id=customer_id)
except Customer.DoesNotExist: except Customer.DoesNotExist:
raise ValueError(f'客户 {customer_id} 不存在') raise ValueError(f"客户 {customer_id} 不存在")
# merchant 隔离 # merchant 隔离
emp = getattr(created_by, 'employee', None) emp = getattr(created_by, "employee", None)
merchant = getattr(emp, 'merchant', None) if emp else None merchant = getattr(emp, "merchant", None) if emp else None
if not merchant: if not merchant:
raise ValueError('用户未关联商户,无法创建出货单') raise ValueError("用户未关联商户,无法创建出货单")
if customer.merchant_id != merchant.id: 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: if not external_id:
raise ValueError('external_id 不能为空') raise ValueError("external_id 不能为空")
if not external_finished_products: if not external_finished_products:
raise ValueError('external_finished_products 不能为空') raise ValueError("external_finished_products 不能为空")
shipment = Shipment.objects.create( shipment = Shipment.objects.create(
merchant=merchant, merchant=merchant,
customer=customer, customer=customer,
shipment_date=shipment_date, shipment_date=shipment_date,
area=(area or '').strip(), area=(area or "").strip(),
remark=remark, remark=remark,
created_by=created_by, created_by=created_by,
external_id=external_id, external_id=external_id,
@@ -171,13 +174,94 @@ def create_external_shipment(
objs = [] objs = []
for item in external_finished_products: for item in external_finished_products:
objs.append(ExternalFinishedProduct( objs.append(
ExternalFinishedProduct(
shipment=shipment, shipment=shipment,
style_name=item.get('style_name', ''), style_name=item.get("style_name", ""),
num_of_rolls=item.get('num_of_rolls', 0), num_of_rolls=item.get("num_of_rolls", 0),
remark=item.get('remark') or '', remark=item.get("remark") or "",
created_by=created_by, created_by=created_by,
)) )
)
ExternalFinishedProduct.objects.bulk_create(objs) ExternalFinishedProduct.objects.bulk_create(objs)
return shipment 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