forked from erp-dev/erp
fix: added merchant_id in models of shipment module
This commit is contained in:
@@ -25,7 +25,7 @@ from .views.products import ProductQuickViewSet
|
|||||||
from .views.parameters import StateParameterViewSet
|
from .views.parameters import StateParameterViewSet
|
||||||
from .views.users import CreateUserWithProfileView
|
from .views.users import CreateUserWithProfileView
|
||||||
from .views.mingdaoyun import MDYPlateOrderStagingViewSet
|
from .views.mingdaoyun import MDYPlateOrderStagingViewSet
|
||||||
from .views.shipment import SalesItemByPrintingOrderView, ShipmentCreateView
|
from .views.shipment import SalesItemByPrintingOrderView, ShipmentCreateView, ShipmentExternalCreateView
|
||||||
|
|
||||||
# 创建 DRF Router for Stateflow
|
# 创建 DRF Router for Stateflow
|
||||||
stateflow_router = DefaultRouter()
|
stateflow_router = DefaultRouter()
|
||||||
@@ -105,6 +105,11 @@ urlpatterns = [
|
|||||||
ShipmentCreateView.as_view(),
|
ShipmentCreateView.as_view(),
|
||||||
name='shipment_create'
|
name='shipment_create'
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
'shipment/shipments/external/',
|
||||||
|
ShipmentExternalCreateView.as_view(),
|
||||||
|
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(),
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ Shipment API 模块
|
|||||||
|
|
||||||
提供出货单和销售品相关的 API 接口
|
提供出货单和销售品相关的 API 接口
|
||||||
"""
|
"""
|
||||||
from .views import SalesItemByPrintingOrderView, ShipmentCreateView
|
from .views import SalesItemByPrintingOrderView, ShipmentCreateView, ShipmentExternalCreateView
|
||||||
|
|
||||||
__all__ = ['SalesItemByPrintingOrderView', 'ShipmentCreateView']
|
__all__ = ['SalesItemByPrintingOrderView', 'ShipmentCreateView', 'ShipmentExternalCreateView']
|
||||||
|
|||||||
@@ -14,12 +14,23 @@ class ShipmentSerializer(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()
|
||||||
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_name = serializers.SerializerMethodField()
|
||||||
|
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)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Shipment
|
model = Shipment
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'customer', 'customer_name', 'shipment_date', 'remark',
|
'id', 'merchant_id', 'merchant_name',
|
||||||
|
'customer', 'customer_name', 'shipment_date', 'remark',
|
||||||
|
'status', 'status_display',
|
||||||
|
'external_id',
|
||||||
|
'cancelled_at', 'cancelled_by_id', 'cancelled_by_name',
|
||||||
'items_count', 'created_by_id', 'created_by_name',
|
'items_count', 'created_by_id', 'created_by_name',
|
||||||
|
'external_finished_products_count',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
@@ -35,10 +46,21 @@ class ShipmentSerializer(serializers.ModelSerializer):
|
|||||||
def get_items_count(self, obj):
|
def get_items_count(self, obj):
|
||||||
return obj.items.count()
|
return obj.items.count()
|
||||||
|
|
||||||
|
def get_cancelled_by_name(self, obj):
|
||||||
|
if obj.cancelled_by:
|
||||||
|
employee = getattr(obj.cancelled_by, 'employee', None)
|
||||||
|
if employee:
|
||||||
|
return employee.name
|
||||||
|
return obj.cancelled_by.username
|
||||||
|
return None
|
||||||
|
|
||||||
class ShipmentCreateSerializer(serializers.Serializer):
|
def get_external_finished_products_count(self, obj):
|
||||||
|
return obj.external_finished_products.count()
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentCreateNormalSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
出货单创建序列化器
|
出货单创建序列化器(普通版)
|
||||||
"""
|
"""
|
||||||
customer = serializers.IntegerField(help_text='客户ID')
|
customer = serializers.IntegerField(help_text='客户ID')
|
||||||
shipment_date = serializers.DateField(help_text='出货日期')
|
shipment_date = serializers.DateField(help_text='出货日期')
|
||||||
@@ -54,6 +76,42 @@ class ShipmentCreateSerializer(serializers.Serializer):
|
|||||||
# 去重
|
# 去重
|
||||||
return list(set(value)) if value else []
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentCreateExternalSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
出货单创建序列化器(external 版)
|
||||||
|
|
||||||
|
特点:
|
||||||
|
- 不绑定任何 SalesItem
|
||||||
|
- 必须提供 external_id
|
||||||
|
- 同时写入 ExternalFinishedProduct 列表并关联到 Shipment
|
||||||
|
"""
|
||||||
|
customer = serializers.IntegerField(help_text='客户ID')
|
||||||
|
shipment_date = serializers.DateField(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='外部成品表结构数组(必填)'
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_external_id(self, value):
|
||||||
|
value = (value or '').strip()
|
||||||
|
if not value:
|
||||||
|
raise serializers.ValidationError('external_id 不能为空')
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_external_finished_products(self, value):
|
||||||
|
if not value:
|
||||||
|
raise serializers.ValidationError('external_finished_products 不能为空')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SalesItemSerializer(serializers.Serializer):
|
class SalesItemSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase):
|
|||||||
|
|
||||||
# 创建出货单
|
# 创建出货单
|
||||||
self.shipment = shipment_models.Shipment.objects.create(
|
self.shipment = shipment_models.Shipment.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
shipment_date='2026-01-14',
|
shipment_date='2026-01-14',
|
||||||
created_by=self.user,
|
created_by=self.user,
|
||||||
@@ -113,6 +114,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase):
|
|||||||
|
|
||||||
# 创建销售品 - 未关联出货单
|
# 创建销售品 - 未关联出货单
|
||||||
self.sales_item1 = shipment_models.SalesItem.objects.create(
|
self.sales_item1 = shipment_models.SalesItem.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
name='销售品1',
|
name='销售品1',
|
||||||
quantity=Decimal('50.00'),
|
quantity=Decimal('50.00'),
|
||||||
unit=shipment_models.UnitChoices.METER,
|
unit=shipment_models.UnitChoices.METER,
|
||||||
@@ -121,6 +123,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.sales_item2 = shipment_models.SalesItem.objects.create(
|
self.sales_item2 = shipment_models.SalesItem.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
name='销售品2',
|
name='销售品2',
|
||||||
quantity=Decimal('30.00'),
|
quantity=Decimal('30.00'),
|
||||||
unit=shipment_models.UnitChoices.METER,
|
unit=shipment_models.UnitChoices.METER,
|
||||||
@@ -132,6 +135,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase):
|
|||||||
|
|
||||||
# 创建销售品 - 已关联出货单
|
# 创建销售品 - 已关联出货单
|
||||||
self.sales_item3 = shipment_models.SalesItem.objects.create(
|
self.sales_item3 = shipment_models.SalesItem.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
name='销售品3(已出货)',
|
name='销售品3(已出货)',
|
||||||
quantity=Decimal('100.00'),
|
quantity=Decimal('100.00'),
|
||||||
unit=shipment_models.UnitChoices.METER,
|
unit=shipment_models.UnitChoices.METER,
|
||||||
@@ -142,6 +146,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase):
|
|||||||
|
|
||||||
# 创建与该订单无关的销售品
|
# 创建与该订单无关的销售品
|
||||||
self.sales_item_other = shipment_models.SalesItem.objects.create(
|
self.sales_item_other = shipment_models.SalesItem.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
name='其它销售品',
|
name='其它销售品',
|
||||||
quantity=Decimal('999.00'),
|
quantity=Decimal('999.00'),
|
||||||
unit=shipment_models.UnitChoices.PIECE,
|
unit=shipment_models.UnitChoices.PIECE,
|
||||||
@@ -304,6 +309,7 @@ class ShipmentCreateAPITestCase(TestCase):
|
|||||||
|
|
||||||
# 创建销售品(未关联出货单)
|
# 创建销售品(未关联出货单)
|
||||||
self.sales_item1 = shipment_models.SalesItem.objects.create(
|
self.sales_item1 = shipment_models.SalesItem.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
name='销售品1',
|
name='销售品1',
|
||||||
quantity=Decimal('50.00'),
|
quantity=Decimal('50.00'),
|
||||||
unit=shipment_models.UnitChoices.METER,
|
unit=shipment_models.UnitChoices.METER,
|
||||||
@@ -311,6 +317,7 @@ class ShipmentCreateAPITestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.sales_item2 = shipment_models.SalesItem.objects.create(
|
self.sales_item2 = shipment_models.SalesItem.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
name='销售品2',
|
name='销售品2',
|
||||||
quantity=Decimal('30.00'),
|
quantity=Decimal('30.00'),
|
||||||
unit=shipment_models.UnitChoices.METER,
|
unit=shipment_models.UnitChoices.METER,
|
||||||
@@ -319,11 +326,13 @@ class ShipmentCreateAPITestCase(TestCase):
|
|||||||
|
|
||||||
# 创建已关联出货单的销售品
|
# 创建已关联出货单的销售品
|
||||||
self.existing_shipment = shipment_models.Shipment.objects.create(
|
self.existing_shipment = shipment_models.Shipment.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
shipment_date='2026-01-13',
|
shipment_date='2026-01-13',
|
||||||
created_by=self.user,
|
created_by=self.user,
|
||||||
)
|
)
|
||||||
self.sales_item_shipped = shipment_models.SalesItem.objects.create(
|
self.sales_item_shipped = shipment_models.SalesItem.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
name='销售品3(已出货)',
|
name='销售品3(已出货)',
|
||||||
quantity=Decimal('100.00'),
|
quantity=Decimal('100.00'),
|
||||||
unit=shipment_models.UnitChoices.METER,
|
unit=shipment_models.UnitChoices.METER,
|
||||||
@@ -426,3 +435,121 @@ class ShipmentCreateAPITestCase(TestCase):
|
|||||||
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)
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentExternalCreateAPITestCase(TestCase):
|
||||||
|
"""测试创建出货单 external 版 API"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
# 创建商户
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='测试印花厂',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建用户
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建客户(需要同 merchant)
|
||||||
|
self.customer = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='测试客户Ext',
|
||||||
|
mobile='13900139002',
|
||||||
|
area='测试地区Ext'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建一个销售品(用于验证 external 版不会绑定任何销售品)
|
||||||
|
self.sales_item = shipment_models.SalesItem.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='销售品-不应被绑定',
|
||||||
|
quantity=Decimal('10.00'),
|
||||||
|
unit=shipment_models.UnitChoices.METER,
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def test_create_external_shipment_success(self):
|
||||||
|
data = {
|
||||||
|
'customer': self.customer.id,
|
||||||
|
'shipment_date': '2026-01-14',
|
||||||
|
'remark': 'external 备注',
|
||||||
|
'external_id': 'EXT-ORDER-001',
|
||||||
|
'external_finished_products': [
|
||||||
|
{'style_name': '款式A', 'num_of_rolls': 2},
|
||||||
|
{'style_name': '款式B', 'num_of_rolls': 5},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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['items_count'], 0)
|
||||||
|
self.assertEqual(result['external_finished_products_count'], 2)
|
||||||
|
|
||||||
|
shipment_id = result['id']
|
||||||
|
|
||||||
|
# 验证外部成品表写入并关联
|
||||||
|
self.assertEqual(
|
||||||
|
shipment_models.ExternalFinishedProduct.objects.filter(shipment_id=shipment_id).count(),
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证不会绑定任何销售品
|
||||||
|
self.sales_item.refresh_from_db()
|
||||||
|
self.assertIsNone(self.sales_item.shipment_id)
|
||||||
|
|
||||||
|
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},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
|
||||||
|
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': []
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
|
||||||
|
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},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ from rest_framework.views import APIView
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
from .serializers import SalesItemSerializer, ShipmentSerializer, ShipmentCreateSerializer
|
from .serializers import (
|
||||||
|
SalesItemSerializer,
|
||||||
|
ShipmentSerializer,
|
||||||
|
ShipmentCreateNormalSerializer,
|
||||||
|
ShipmentCreateExternalSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ShipmentCreateView(APIView):
|
class ShipmentCreateView(APIView):
|
||||||
@@ -41,7 +46,7 @@ class ShipmentCreateView(APIView):
|
|||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
# 验证请求数据
|
# 验证请求数据
|
||||||
serializer = ShipmentCreateSerializer(data=request.data)
|
serializer = ShipmentCreateNormalSerializer(data=request.data)
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@@ -65,6 +70,43 @@ class ShipmentCreateView(APIView):
|
|||||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentExternalCreateView(APIView):
|
||||||
|
"""
|
||||||
|
创建出货单(external 版)
|
||||||
|
|
||||||
|
POST /api/v1/shipment/shipments/external/
|
||||||
|
|
||||||
|
特点:
|
||||||
|
- external_id 必填
|
||||||
|
- external_finished_products 必填(数组)
|
||||||
|
- 不绑定任何销售品
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = ShipmentCreateExternalSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
data = serializer.validated_data
|
||||||
|
|
||||||
|
from shipment.services import create_external_shipment
|
||||||
|
try:
|
||||||
|
shipment = create_external_shipment(
|
||||||
|
customer_id=data['customer'],
|
||||||
|
shipment_date=data['shipment_date'],
|
||||||
|
external_id=data['external_id'],
|
||||||
|
external_finished_products=data['external_finished_products'],
|
||||||
|
created_by=request.user,
|
||||||
|
remark=data.get('remark', ''),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
response_serializer = ShipmentSerializer(shipment)
|
||||||
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
class SalesItemByPrintingOrderView(APIView):
|
class SalesItemByPrintingOrderView(APIView):
|
||||||
"""
|
"""
|
||||||
通过生产订单查询销售品
|
通过生产订单查询销售品
|
||||||
|
|||||||
@@ -156,6 +156,39 @@ python manage.py backfill_merchant <merchant_id>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 9. Shipment 创建 API:普通版 & External 版
|
||||||
|
|
||||||
|
新增并完善了两个创建出货单接口:
|
||||||
|
|
||||||
|
#### 普通版(绑定销售品)
|
||||||
|
- **接口**:`POST /api/v1/shipment/shipments/`
|
||||||
|
- **参数**:`sales_items: [销售品ID...]`
|
||||||
|
- **行为**:创建出货单并把销售品关联到该出货单
|
||||||
|
|
||||||
|
#### External 版(不绑定销售品 + 外部成品表批量写入)
|
||||||
|
- **接口**:`POST /api/v1/shipment/shipments/external/`
|
||||||
|
- **参数**:
|
||||||
|
- `external_id` 必填
|
||||||
|
- `external_finished_products`(数组)必填,用于写入 `ExternalFinishedProduct` 并关联到 Shipment
|
||||||
|
- **行为**:创建出货单 + 批量创建外部成品表记录(不关联任何销售品)
|
||||||
|
|
||||||
|
#### 代码位置
|
||||||
|
- `shipment/services.py`: 新增 `create_external_shipment()`
|
||||||
|
- `api_v1/views/shipment/views.py`: 新增 `ShipmentExternalCreateView`
|
||||||
|
- `api_v1/views/shipment/serializers.py`: 新增 external 创建序列化器与响应字段扩展
|
||||||
|
- `api_v1/urls.py`: 注册 external 创建路由
|
||||||
|
- `api_v1/views/shipment/test_api.py`: 新增 external create 测试
|
||||||
|
|
||||||
|
#### merchant 强制归属(非空)
|
||||||
|
- 为 `Shipment` 与 `SalesItem` 增加 `merchant` 外键且不允许为空
|
||||||
|
- 创建时从 `request.user.employee.merchant` 自动绑定,并校验 customer 同商户
|
||||||
|
- 普通版在关联销售品时校验销售品同商户,避免跨商户关联
|
||||||
|
|
||||||
|
#### 文档
|
||||||
|
- `docs/shipment_api.md`: 补充 external create API 文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 待办
|
## 待办
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
"merchant_id": 1,
|
||||||
|
"merchant_name": "测试印花厂",
|
||||||
"customer": 1,
|
"customer": 1,
|
||||||
"customer_name": "客户A",
|
"customer_name": "客户A",
|
||||||
"shipment_date": "2026-01-14",
|
"shipment_date": "2026-01-14",
|
||||||
@@ -61,6 +63,8 @@
|
|||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| id | int | 出货单ID |
|
| id | int | 出货单ID |
|
||||||
|
| merchant_id | int | 所属商户ID(从当前登录用户推导) |
|
||||||
|
| merchant_name | string | 所属商户名称(从当前登录用户推导) |
|
||||||
| customer | int | 客户ID |
|
| customer | int | 客户ID |
|
||||||
| customer_name | string | 客户名称 |
|
| customer_name | string | 客户名称 |
|
||||||
| shipment_date | string | 出货日期 |
|
| shipment_date | string | 出货日期 |
|
||||||
@@ -99,6 +103,94 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 创建出货单(external 版)
|
||||||
|
|
||||||
|
创建出货单但**不绑定任何销售品**,同时写入并关联“外部成品表”(用于兼容外部遗留系统)。
|
||||||
|
|
||||||
|
### 接口信息
|
||||||
|
|
||||||
|
- **URL**: `/api/v1/shipment/shipments/external/`
|
||||||
|
- **Method**: `POST`
|
||||||
|
- **认证**: 需要登录(JWT Token)
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| customer | int | 是 | 客户ID |
|
||||||
|
| shipment_date | string | 是 | 出货日期(YYYY-MM-DD) |
|
||||||
|
| remark | string | 否 | 备注 |
|
||||||
|
| external_id | string | 是 | 外部订单号(长度<=120) |
|
||||||
|
| external_finished_products | array[object] | 是 | 外部成品表结构数组(至少 1 条) |
|
||||||
|
|
||||||
|
`external_finished_products` 每项结构:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| style_name | string | 是 | 款式名称 |
|
||||||
|
| num_of_rolls | int | 是 | 卷数 |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customer": 1,
|
||||||
|
"shipment_date": "2026-01-14",
|
||||||
|
"remark": "external 备注(可选)",
|
||||||
|
"external_id": "EXT-ORDER-001",
|
||||||
|
"external_finished_products": [
|
||||||
|
{"style_name": "款式A", "num_of_rolls": 2},
|
||||||
|
{"style_name": "款式B", "num_of_rolls": 5}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"merchant_id": 1,
|
||||||
|
"merchant_name": "测试印花厂",
|
||||||
|
"customer": 1,
|
||||||
|
"customer_name": "客户A",
|
||||||
|
"shipment_date": "2026-01-14",
|
||||||
|
"remark": "external 备注(可选)",
|
||||||
|
"status": 1,
|
||||||
|
"status_display": "待送货",
|
||||||
|
"external_id": "EXT-ORDER-001",
|
||||||
|
"cancelled_at": null,
|
||||||
|
"cancelled_by_id": null,
|
||||||
|
"cancelled_by_name": null,
|
||||||
|
"items_count": 0,
|
||||||
|
"external_finished_products_count": 2,
|
||||||
|
"created_by_id": 1,
|
||||||
|
"created_by_name": "张三",
|
||||||
|
"created_at": "2026-01-14T10:00:00Z",
|
||||||
|
"updated_at": "2026-01-14T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应
|
||||||
|
|
||||||
|
#### 400 Bad Request - external_id 为空
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"external_id": ["external_id 不能为空"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 400 Bad Request - external_finished_products 为空
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"external_finished_products": ["external_finished_products 不能为空"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 通过生产订单查询销售品
|
## 通过生产订单查询销售品
|
||||||
|
|
||||||
查询与指定生产订单(PrintingOrder)关联的所有销售品(SalesItem)。
|
查询与指定生产订单(PrintingOrder)关联的所有销售品(SalesItem)。
|
||||||
|
|||||||
@@ -114,8 +114,22 @@ def on_printing_job_process_completed(sender, **kwargs):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
# SalesItem.merchant 不允许为空,优先取 printing_job.merchant
|
||||||
|
sales_merchant = getattr(printing_job, 'merchant', None)
|
||||||
|
if not sales_merchant and getattr(printing_job, 'printing_order', None):
|
||||||
|
sales_merchant = getattr(printing_job.printing_order, 'merchant', None)
|
||||||
|
if not sales_merchant and last_completed_by and hasattr(last_completed_by, 'employee'):
|
||||||
|
sales_merchant = getattr(last_completed_by.employee, 'merchant', None)
|
||||||
|
if not sales_merchant:
|
||||||
|
logger.warning(
|
||||||
|
f'PrintingJob #{printing_job.id} 流程完成,'
|
||||||
|
f'但无法确定 merchant,跳过销售品创建'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
sales_item = SalesItem.objects.create(
|
sales_item = SalesItem.objects.create(
|
||||||
shipment=None, # 暂不关联出货单,待后续分配
|
shipment=None, # 暂不关联出货单,待后续分配
|
||||||
|
merchant=sales_merchant,
|
||||||
name=product_name,
|
name=product_name,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
unit=sales_unit,
|
unit=sales_unit,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_shipment_salesitem_merchant(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
为历史数据补齐 merchant(本项目当前声称未上线,但迁移要可重复执行且安全)。
|
||||||
|
- Shipment.merchant: 从 customer.merchant 推导
|
||||||
|
- SalesItem.merchant:
|
||||||
|
- 优先从 shipment.merchant 推导
|
||||||
|
- 否则从 created_by.employee.merchant 推导(如果存在)
|
||||||
|
"""
|
||||||
|
Shipment = apps.get_model('shipment', 'Shipment')
|
||||||
|
SalesItem = apps.get_model('shipment', 'SalesItem')
|
||||||
|
|
||||||
|
# Shipment: merchant = customer.merchant
|
||||||
|
for s in Shipment.objects.filter(merchant__isnull=True).select_related('customer'):
|
||||||
|
if s.customer_id:
|
||||||
|
s.merchant_id = s.customer.merchant_id
|
||||||
|
s.save(update_fields=['merchant'])
|
||||||
|
|
||||||
|
# SalesItem: merchant = shipment.merchant else created_by.employee.merchant
|
||||||
|
# 注意:迁移态下 employee 关系可能不存在,需 try/except
|
||||||
|
for item in SalesItem.objects.filter(merchant__isnull=True).select_related('shipment', 'shipment__merchant', 'created_by'):
|
||||||
|
if item.shipment_id and getattr(item.shipment, 'merchant_id', None):
|
||||||
|
item.merchant_id = item.shipment.merchant_id
|
||||||
|
item.save(update_fields=['merchant'])
|
||||||
|
continue
|
||||||
|
|
||||||
|
user = getattr(item, 'created_by', None)
|
||||||
|
emp = getattr(user, 'employee', None) if user else None
|
||||||
|
merchant_id = getattr(emp, 'merchant_id', None) if emp else None
|
||||||
|
if merchant_id:
|
||||||
|
item.merchant_id = merchant_id
|
||||||
|
item.save(update_fields=['merchant'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('basic_info', '0013_alter_customer_options'),
|
||||||
|
('shipment', '0005_fix_shipment_external_finished_product_relation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='shipment',
|
||||||
|
name='merchant',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='shipments',
|
||||||
|
to='basic_info.merchant',
|
||||||
|
verbose_name='所属商户',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='salesitem',
|
||||||
|
name='merchant',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='sales_items',
|
||||||
|
to='basic_info.merchant',
|
||||||
|
verbose_name='所属商户',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill_shipment_salesitem_merchant, migrations.RunPython.noop),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='shipment',
|
||||||
|
name='merchant',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='shipments',
|
||||||
|
to='basic_info.merchant',
|
||||||
|
verbose_name='所属商户',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='salesitem',
|
||||||
|
name='merchant',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='sales_items',
|
||||||
|
to='basic_info.merchant',
|
||||||
|
verbose_name='所属商户',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -103,6 +103,13 @@ class Shipment(ModelBase):
|
|||||||
help_text='用于兼容外部遗留系统的订单号(可空)',
|
help_text='用于兼容外部遗留系统的订单号(可空)',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
merchant = models.ForeignKey(
|
||||||
|
basic_models.Merchant,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='shipments',
|
||||||
|
verbose_name='所属商户',
|
||||||
|
)
|
||||||
|
|
||||||
customer = models.ForeignKey(
|
customer = models.ForeignKey(
|
||||||
basic_models.Customer,
|
basic_models.Customer,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@@ -168,6 +175,13 @@ class SalesItem(ModelBase):
|
|||||||
help_text='为空表示待分配'
|
help_text='为空表示待分配'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
merchant = models.ForeignKey(
|
||||||
|
basic_models.Merchant,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='sales_items',
|
||||||
|
verbose_name='所属商户',
|
||||||
|
)
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
verbose_name='名称'
|
verbose_name='名称'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import List
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
from shipment.models import SalesItem, Shipment
|
from shipment.models import ExternalFinishedProduct, SalesItem, Shipment
|
||||||
|
|
||||||
|
|
||||||
def get_sales_items_by_printing_order(
|
def get_sales_items_by_printing_order(
|
||||||
@@ -74,6 +74,15 @@ def create_shipment(
|
|||||||
except Customer.DoesNotExist:
|
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
|
||||||
|
if not merchant:
|
||||||
|
# superuser 也必须绑定 merchant(避免产生无法隔离的数据)
|
||||||
|
raise ValueError('用户未关联商户,无法创建出货单')
|
||||||
|
if customer.merchant_id != merchant.id:
|
||||||
|
raise ValueError('无权限为该客户创建出货单')
|
||||||
|
|
||||||
# 验证销售品
|
# 验证销售品
|
||||||
if sales_item_ids:
|
if sales_item_ids:
|
||||||
# 查询销售品
|
# 查询销售品
|
||||||
@@ -92,6 +101,7 @@ def create_shipment(
|
|||||||
|
|
||||||
# 创建出货单
|
# 创建出货单
|
||||||
shipment = Shipment.objects.create(
|
shipment = Shipment.objects.create(
|
||||||
|
merchant=merchant,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
shipment_date=shipment_date,
|
shipment_date=shipment_date,
|
||||||
remark=remark,
|
remark=remark,
|
||||||
@@ -100,6 +110,69 @@ def create_shipment(
|
|||||||
|
|
||||||
# 关联销售品
|
# 关联销售品
|
||||||
if sales_item_ids:
|
if sales_item_ids:
|
||||||
SalesItem.objects.filter(id__in=sales_item_ids).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('存在不属于当前商户的销售品,无法关联到出货单')
|
||||||
|
|
||||||
return shipment
|
return shipment
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_external_shipment(
|
||||||
|
customer_id: int,
|
||||||
|
shipment_date,
|
||||||
|
external_id: str,
|
||||||
|
external_finished_products: List[dict],
|
||||||
|
created_by,
|
||||||
|
remark: str = '',
|
||||||
|
) -> Shipment:
|
||||||
|
"""
|
||||||
|
创建出货单(external 版),并批量写入外部成品表并关联到出货单。
|
||||||
|
|
||||||
|
特点:
|
||||||
|
- 不绑定任何 SalesItem
|
||||||
|
- external_id 必填
|
||||||
|
- external_finished_products 必填(至少 1 条)
|
||||||
|
"""
|
||||||
|
from basic_info.models import Customer
|
||||||
|
|
||||||
|
# 验证客户存在
|
||||||
|
try:
|
||||||
|
customer = Customer.objects.get(id=customer_id)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
raise ValueError(f'客户 {customer_id} 不存在')
|
||||||
|
|
||||||
|
# merchant 隔离
|
||||||
|
emp = getattr(created_by, 'employee', None)
|
||||||
|
merchant = getattr(emp, 'merchant', None) if emp else None
|
||||||
|
if not merchant:
|
||||||
|
raise ValueError('用户未关联商户,无法创建出货单')
|
||||||
|
if customer.merchant_id != merchant.id:
|
||||||
|
raise ValueError('无权限为该客户创建出货单')
|
||||||
|
|
||||||
|
external_id = (external_id or '').strip()
|
||||||
|
if not external_id:
|
||||||
|
raise ValueError('external_id 不能为空')
|
||||||
|
if not external_finished_products:
|
||||||
|
raise ValueError('external_finished_products 不能为空')
|
||||||
|
|
||||||
|
shipment = Shipment.objects.create(
|
||||||
|
merchant=merchant,
|
||||||
|
customer=customer,
|
||||||
|
shipment_date=shipment_date,
|
||||||
|
remark=remark,
|
||||||
|
created_by=created_by,
|
||||||
|
external_id=external_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
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),
|
||||||
|
created_by=created_by,
|
||||||
|
))
|
||||||
|
|
||||||
|
ExternalFinishedProduct.objects.bulk_create(objs)
|
||||||
|
return shipment
|
||||||
|
|||||||
Reference in New Issue
Block a user